1use std::fmt;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum LayoutSource {
15 Detector,
17 Constant,
19 NdAttribute,
21 Unset,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum LayoutDataType {
30 Int,
31 Float,
32 String,
33}
34
35impl LayoutDataType {
36 fn parse(s: &str) -> LayoutDataType {
37 match s {
38 "int" => LayoutDataType::Int,
39 "float" => LayoutDataType::Float,
40 _ => LayoutDataType::String,
41 }
42 }
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum LayoutWhen {
48 OnFileOpen,
49 OnFileClose,
50 OnFileWrite,
51 OnFrame,
53}
54
55impl LayoutWhen {
56 fn parse(s: &str) -> LayoutWhen {
57 match s {
58 "OnFileOpen" => LayoutWhen::OnFileOpen,
59 "OnFileClose" => LayoutWhen::OnFileClose,
60 "OnFileWrite" => LayoutWhen::OnFileWrite,
61 _ => LayoutWhen::OnFrame,
62 }
63 }
64}
65
66#[derive(Debug, Clone)]
68pub struct LayoutAttribute {
69 pub name: String,
70 pub source: LayoutSource,
71 pub data_type: LayoutDataType,
72 pub value: String,
73 pub ndattribute: String,
75 pub when: LayoutWhen,
76}
77
78#[derive(Debug, Clone)]
83pub struct NdAttrElementAttr {
84 pub element_path: String,
86 pub is_dataset: bool,
88 pub attr_name: String,
90 pub ndattribute: String,
92 pub when: LayoutWhen,
95}
96
97#[derive(Debug, Clone)]
99pub struct LayoutDataset {
100 pub name: String,
101 pub source: LayoutSource,
102 pub data_type: LayoutDataType,
103 pub value: String,
104 pub ndattribute: String,
106 pub det_default: bool,
108 pub when: LayoutWhen,
109 pub attributes: Vec<LayoutAttribute>,
111}
112
113#[derive(Debug, Clone)]
115pub struct LayoutHardlink {
116 pub name: String,
117 pub target: String,
118}
119
120#[derive(Debug, Clone)]
122pub struct LayoutGroup {
123 pub name: String,
124 pub ndattr_default: bool,
126 pub attributes: Vec<LayoutAttribute>,
127 pub datasets: Vec<LayoutDataset>,
128 pub hardlinks: Vec<LayoutHardlink>,
129 pub groups: Vec<LayoutGroup>,
130}
131
132impl LayoutGroup {
133 fn new(name: String, ndattr_default: bool) -> Self {
134 Self {
135 name,
136 ndattr_default,
137 attributes: Vec::new(),
138 datasets: Vec::new(),
139 hardlinks: Vec::new(),
140 groups: Vec::new(),
141 }
142 }
143}
144
145#[derive(Debug, Clone, Default)]
147pub struct Hdf5Layout {
148 pub groups: Vec<LayoutGroup>,
150 pub detector_data_destination: Option<String>,
152}
153
154#[derive(Debug, Clone)]
156pub struct LayoutError(pub String);
157
158impl fmt::Display for LayoutError {
159 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160 write!(f, "{}", self.0)
161 }
162}
163
164impl std::error::Error for LayoutError {}
165
166impl Hdf5Layout {
167 pub fn from_file(path: &std::path::Path) -> Result<Hdf5Layout, LayoutError> {
169 let text = std::fs::read_to_string(path)
170 .map_err(|e| LayoutError(format!("cannot read layout file: {}", e)))?;
171 Self::parse(&text)
172 }
173
174 pub fn parse(text: &str) -> Result<Hdf5Layout, LayoutError> {
176 let tokens = tokenize(text)?;
177 let mut parser = Parser { tokens, pos: 0 };
178 parser.parse_document()
179 }
180
181 pub fn for_each_dataset<F: FnMut(&str, &LayoutDataset)>(&self, mut f: F) {
183 fn recurse<F: FnMut(&str, &LayoutDataset)>(g: &LayoutGroup, path: &str, f: &mut F) {
184 let here = if path.is_empty() {
185 format!("/{}", g.name)
186 } else {
187 format!("{}/{}", path, g.name)
188 };
189 for d in &g.datasets {
190 f(&here, d);
191 }
192 for sub in &g.groups {
193 recurse(sub, &here, f);
194 }
195 }
196 for g in &self.groups {
197 recurse(g, "", &mut f);
198 }
199 }
200
201 pub fn detector_dataset_path(&self) -> Option<String> {
203 let mut found = None;
204 self.for_each_dataset(|path, d| {
205 if d.det_default && d.source == LayoutSource::Detector && found.is_none() {
206 found = Some(format!("{}/{}", path, d.name));
207 }
208 });
209 if found.is_none() {
210 self.for_each_dataset(|path, d| {
212 if d.source == LayoutSource::Detector && found.is_none() {
213 found = Some(format!("{}/{}", path, d.name));
214 }
215 });
216 }
217 found
218 }
219
220 pub fn detector_dataset_paths(&self) -> Vec<String> {
228 let mut out = Vec::new();
229 self.for_each_dataset(|path, d| {
230 if d.source == LayoutSource::Detector {
231 out.push(format!("{}/{}", path, d.name));
232 }
233 });
234 out
235 }
236
237 pub fn dataset_group_path(&self, name: &str) -> Option<String> {
241 let mut found = None;
242 self.for_each_dataset(|path, d| {
243 if d.name == name && found.is_none() {
244 found = Some(path.to_string());
245 }
246 });
247 found
248 }
249
250 pub fn ndattribute_dataset(&self, name: &str) -> Option<(String, String)> {
256 let mut found = None;
257 self.for_each_dataset(|path, d| {
258 if found.is_none() && d.source == LayoutSource::NdAttribute && d.ndattribute == name {
259 found = Some((path.to_string(), d.name.clone()));
260 }
261 });
262 found
263 }
264
265 pub fn ndattribute_element_attrs(&self) -> Vec<NdAttrElementAttr> {
271 fn push_attrs(
272 out: &mut Vec<NdAttrElementAttr>,
273 element_path: &str,
274 is_dataset: bool,
275 attrs: &[LayoutAttribute],
276 ) {
277 for a in attrs {
278 if a.source == LayoutSource::NdAttribute {
279 out.push(NdAttrElementAttr {
280 element_path: element_path.to_string(),
281 is_dataset,
282 attr_name: a.name.clone(),
283 ndattribute: a.ndattribute.clone(),
284 when: a.when,
285 });
286 }
287 }
288 }
289 fn recurse(out: &mut Vec<NdAttrElementAttr>, g: &LayoutGroup, path: &str) {
290 let here = if path.is_empty() {
291 format!("/{}", g.name)
292 } else {
293 format!("{}/{}", path, g.name)
294 };
295 push_attrs(out, &here, false, &g.attributes);
296 for d in &g.datasets {
297 let dpath = format!("{}/{}", here, d.name);
298 push_attrs(out, &dpath, true, &d.attributes);
299 }
300 for sub in &g.groups {
301 recurse(out, sub, &here);
302 }
303 }
304 let mut out = Vec::new();
305 for g in &self.groups {
306 recurse(&mut out, g, "");
307 }
308 out
309 }
310
311 pub fn ndattr_default_group(&self) -> Option<String> {
313 fn recurse(g: &LayoutGroup, path: &str) -> Option<String> {
314 let here = if path.is_empty() {
315 format!("/{}", g.name)
316 } else {
317 format!("{}/{}", path, g.name)
318 };
319 if g.ndattr_default {
320 return Some(here.clone());
321 }
322 for sub in &g.groups {
323 if let Some(p) = recurse(sub, &here) {
324 return Some(p);
325 }
326 }
327 None
328 }
329 for g in &self.groups {
330 if let Some(p) = recurse(g, "") {
331 return Some(p);
332 }
333 }
334 None
335 }
336}
337
338#[derive(Debug, Clone)]
343enum Token {
344 Open {
346 name: String,
347 attrs: Vec<(String, String)>,
348 self_closing: bool,
349 },
350 Close(String),
352}
353
354fn tokenize(text: &str) -> Result<Vec<Token>, LayoutError> {
358 let bytes = text.as_bytes();
359 let mut tokens = Vec::new();
360 let mut i = 0;
361 while i < bytes.len() {
362 if bytes[i] != b'<' {
363 i += 1;
364 continue;
365 }
366 if text[i..].starts_with("<!--") {
368 match text[i..].find("-->") {
369 Some(end) => {
370 i += end + 3;
371 continue;
372 }
373 None => return Err(LayoutError("unterminated XML comment".into())),
374 }
375 }
376 if text[i..].starts_with("<?") || text[i..].starts_with("<!") {
378 match text[i..].find('>') {
379 Some(end) => {
380 i += end + 1;
381 continue;
382 }
383 None => return Err(LayoutError("unterminated XML declaration".into())),
384 }
385 }
386 let close = text[i..]
388 .find('>')
389 .ok_or_else(|| LayoutError("unterminated XML tag".into()))?;
390 let inner = &text[i + 1..i + close];
391 i += close + 1;
392
393 let inner_trim = inner.trim();
394 if let Some(rest) = inner_trim.strip_prefix('/') {
395 tokens.push(Token::Close(rest.trim().to_string()));
396 continue;
397 }
398 let self_closing = inner_trim.ends_with('/');
399 let body = if self_closing {
400 inner_trim[..inner_trim.len() - 1].trim()
401 } else {
402 inner_trim
403 };
404 let (name, attrs) = parse_tag_body(body)?;
405 tokens.push(Token::Open {
406 name,
407 attrs,
408 self_closing,
409 });
410 }
411 Ok(tokens)
412}
413
414fn parse_tag_body(body: &str) -> Result<(String, Vec<(String, String)>), LayoutError> {
416 let chars: Vec<char> = body.chars().collect();
417 let mut idx = 0;
418 let name_start = idx;
420 while idx < chars.len() && !chars[idx].is_whitespace() {
421 idx += 1;
422 }
423 let name: String = chars[name_start..idx].iter().collect();
424 if name.is_empty() {
425 return Err(LayoutError("empty XML tag name".into()));
426 }
427 let mut attrs = Vec::new();
428 loop {
429 while idx < chars.len() && chars[idx].is_whitespace() {
430 idx += 1;
431 }
432 if idx >= chars.len() {
433 break;
434 }
435 let attr_start = idx;
437 while idx < chars.len() && chars[idx] != '=' && !chars[idx].is_whitespace() {
438 idx += 1;
439 }
440 let attr_name: String = chars[attr_start..idx].iter().collect();
441 while idx < chars.len() && chars[idx].is_whitespace() {
442 idx += 1;
443 }
444 if idx >= chars.len() || chars[idx] != '=' {
445 return Err(LayoutError(format!(
446 "malformed attribute '{}' in tag '{}'",
447 attr_name, name
448 )));
449 }
450 idx += 1; while idx < chars.len() && chars[idx].is_whitespace() {
452 idx += 1;
453 }
454 if idx >= chars.len() || (chars[idx] != '"' && chars[idx] != '\'') {
455 return Err(LayoutError(format!(
456 "unquoted attribute value for '{}' in tag '{}'",
457 attr_name, name
458 )));
459 }
460 let quote = chars[idx];
461 idx += 1;
462 let val_start = idx;
463 while idx < chars.len() && chars[idx] != quote {
464 idx += 1;
465 }
466 if idx >= chars.len() {
467 return Err(LayoutError(format!(
468 "unterminated attribute value for '{}'",
469 attr_name
470 )));
471 }
472 let raw: String = chars[val_start..idx].iter().collect();
473 idx += 1; attrs.push((attr_name, unescape(&raw)));
475 }
476 Ok((name, attrs))
477}
478
479fn unescape(s: &str) -> String {
481 if !s.contains('&') {
482 return s.to_string();
483 }
484 s.replace("<", "<")
485 .replace(">", ">")
486 .replace(""", "\"")
487 .replace("'", "'")
488 .replace("&", "&")
489}
490
491struct Parser {
496 tokens: Vec<Token>,
497 pos: usize,
498}
499
500impl Parser {
501 fn parse_document(&mut self) -> Result<Hdf5Layout, LayoutError> {
502 let mut layout = Hdf5Layout::default();
504 match self.tokens.get(self.pos).cloned() {
505 Some(Token::Open {
506 name, self_closing, ..
507 }) if name == "hdf5_layout" => {
508 self.pos += 1;
509 if self_closing {
510 return Ok(layout);
511 }
512 }
513 _ => return Err(LayoutError("root element <hdf5_layout> not found".into())),
514 }
515
516 loop {
517 match self.tokens.get(self.pos).cloned() {
518 Some(Token::Open {
519 name,
520 attrs,
521 self_closing,
522 }) => {
523 self.pos += 1;
524 match name.as_str() {
525 "group" => {
526 let g = self.parse_group(attrs, self_closing)?;
527 layout.groups.push(g);
528 }
529 "global" => {
530 let n = attr_get(&attrs, "name").unwrap_or_default();
531 if n == "detector_data_destination" {
532 layout.detector_data_destination = attr_get(&attrs, "ndattribute");
533 }
534 if !self_closing {
535 self.skip_to_close("global")?;
536 }
537 }
538 other => {
539 return Err(LayoutError(format!(
540 "unexpected element <{}> in <hdf5_layout>",
541 other
542 )));
543 }
544 }
545 }
546 Some(Token::Close(name)) if name == "hdf5_layout" => {
547 self.pos += 1;
548 break;
549 }
550 Some(Token::Close(other)) => {
551 return Err(LayoutError(format!(
552 "unexpected </{}> at document level",
553 other
554 )));
555 }
556 None => return Err(LayoutError("unterminated <hdf5_layout> element".into())),
557 }
558 }
559 Ok(layout)
560 }
561
562 fn parse_group(
563 &mut self,
564 attrs: Vec<(String, String)>,
565 self_closing: bool,
566 ) -> Result<LayoutGroup, LayoutError> {
567 let name = attr_get(&attrs, "name")
568 .ok_or_else(|| LayoutError("<group> missing required 'name'".into()))?;
569 let ndattr_default = attr_bool(&attrs, "ndattr_default");
570 let mut group = LayoutGroup::new(name.clone(), ndattr_default);
571 if self_closing {
572 return Ok(group);
573 }
574 loop {
575 match self.tokens.get(self.pos).cloned() {
576 Some(Token::Open {
577 name: child,
578 attrs: cattrs,
579 self_closing: sc,
580 }) => {
581 self.pos += 1;
582 match child.as_str() {
583 "group" => group.groups.push(self.parse_group(cattrs, sc)?),
584 "dataset" => group.datasets.push(self.parse_dataset(cattrs, sc)?),
585 "attribute" => {
586 group.attributes.push(parse_attribute(&cattrs)?);
587 if !sc {
588 self.skip_to_close("attribute")?;
589 }
590 }
591 "hardlink" => {
592 group.hardlinks.push(LayoutHardlink {
593 name: attr_get(&cattrs, "name").ok_or_else(|| {
594 LayoutError("<hardlink> missing 'name'".into())
595 })?,
596 target: attr_get(&cattrs, "target").ok_or_else(|| {
597 LayoutError("<hardlink> missing 'target'".into())
598 })?,
599 });
600 if !sc {
601 self.skip_to_close("hardlink")?;
602 }
603 }
604 other => {
605 return Err(LayoutError(format!(
606 "unexpected <{}> inside <group name=\"{}\">",
607 other, name
608 )));
609 }
610 }
611 }
612 Some(Token::Close(close_name)) if close_name == "group" => {
613 self.pos += 1;
614 break;
615 }
616 Some(Token::Close(other)) => {
617 return Err(LayoutError(format!(
618 "mismatched </{}>, expected </group>",
619 other
620 )));
621 }
622 None => {
623 return Err(LayoutError(format!(
624 "unterminated <group name=\"{}\">",
625 name
626 )));
627 }
628 }
629 }
630 Ok(group)
631 }
632
633 fn parse_dataset(
634 &mut self,
635 attrs: Vec<(String, String)>,
636 self_closing: bool,
637 ) -> Result<LayoutDataset, LayoutError> {
638 let name = attr_get(&attrs, "name")
639 .ok_or_else(|| LayoutError("<dataset> missing required 'name'".into()))?;
640 let source = parse_source(&attrs, &name)?;
641 let mut ds = LayoutDataset {
642 name: name.clone(),
643 source,
644 data_type: LayoutDataType::parse(
645 &attr_get(&attrs, "type").unwrap_or_else(|| "string".into()),
646 ),
647 value: attr_get(&attrs, "value").unwrap_or_default(),
648 ndattribute: attr_get(&attrs, "ndattribute").unwrap_or_default(),
649 det_default: attr_bool(&attrs, "det_default"),
650 when: LayoutWhen::parse(&attr_get(&attrs, "when").unwrap_or_default()),
651 attributes: Vec::new(),
652 };
653 if self_closing {
654 return Ok(ds);
655 }
656 loop {
657 match self.tokens.get(self.pos).cloned() {
658 Some(Token::Open {
659 name: child,
660 attrs: cattrs,
661 self_closing: sc,
662 }) => {
663 self.pos += 1;
664 if child == "attribute" {
665 ds.attributes.push(parse_attribute(&cattrs)?);
666 if !sc {
667 self.skip_to_close("attribute")?;
668 }
669 } else {
670 return Err(LayoutError(format!(
671 "unexpected <{}> inside <dataset name=\"{}\">",
672 child, name
673 )));
674 }
675 }
676 Some(Token::Close(close_name)) if close_name == "dataset" => {
677 self.pos += 1;
678 break;
679 }
680 Some(Token::Close(other)) => {
681 return Err(LayoutError(format!(
682 "mismatched </{}>, expected </dataset>",
683 other
684 )));
685 }
686 None => {
687 return Err(LayoutError(format!(
688 "unterminated <dataset name=\"{}\">",
689 name
690 )));
691 }
692 }
693 }
694 Ok(ds)
695 }
696
697 fn skip_to_close(&mut self, tag: &str) -> Result<(), LayoutError> {
700 let mut depth = 1;
701 while let Some(tok) = self.tokens.get(self.pos).cloned() {
702 self.pos += 1;
703 match tok {
704 Token::Open {
705 name, self_closing, ..
706 } if name == tag && !self_closing => depth += 1,
707 Token::Close(name) if name == tag => {
708 depth -= 1;
709 if depth == 0 {
710 return Ok(());
711 }
712 }
713 _ => {}
714 }
715 }
716 Err(LayoutError(format!("unterminated <{}> element", tag)))
717 }
718}
719
720fn parse_attribute(attrs: &[(String, String)]) -> Result<LayoutAttribute, LayoutError> {
721 let name = attr_get(attrs, "name")
722 .ok_or_else(|| LayoutError("<attribute> missing required 'name'".into()))?;
723 let source_str = attr_get(attrs, "source")
724 .ok_or_else(|| LayoutError(format!("<attribute name=\"{}\"> missing 'source'", name)))?;
725 let source = match source_str.as_str() {
726 "constant" => LayoutSource::Constant,
727 "ndattribute" => LayoutSource::NdAttribute,
728 other => {
729 return Err(LayoutError(format!(
730 "<attribute name=\"{}\"> invalid source '{}'",
731 name, other
732 )));
733 }
734 };
735 Ok(LayoutAttribute {
736 name,
737 source,
738 data_type: LayoutDataType::parse(
739 &attr_get(attrs, "type").unwrap_or_else(|| "string".into()),
740 ),
741 value: attr_get(attrs, "value").unwrap_or_default(),
742 ndattribute: attr_get(attrs, "ndattribute").unwrap_or_default(),
743 when: LayoutWhen::parse(&attr_get(attrs, "when").unwrap_or_default()),
744 })
745}
746
747fn parse_source(attrs: &[(String, String)], name: &str) -> Result<LayoutSource, LayoutError> {
748 let s = match attr_get(attrs, "source") {
752 Some(s) => s,
753 None => return Ok(LayoutSource::Unset),
754 };
755 match s.as_str() {
756 "detector" => Ok(LayoutSource::Detector),
757 "constant" => Ok(LayoutSource::Constant),
758 "ndattribute" => Ok(LayoutSource::NdAttribute),
759 other => Err(LayoutError(format!(
760 "<dataset name=\"{}\"> invalid source '{}'",
761 name, other
762 ))),
763 }
764}
765
766fn attr_get(attrs: &[(String, String)], key: &str) -> Option<String> {
767 attrs.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone())
768}
769
770fn attr_bool(attrs: &[(String, String)], key: &str) -> bool {
771 matches!(attr_get(attrs, key).as_deref(), Some("true") | Some("1"))
772}
773
774#[cfg(test)]
775mod tests {
776 use super::*;
777
778 const SAMPLE: &str = r#"<?xml version="1.0"?>
779<hdf5_layout>
780 <global name="detector_data_destination" ndattribute="detdest" />
781 <group name="entry">
782 <attribute name="NX_class" source="constant" value="NXentry" type="string" />
783 <group name="data" ndattr_default="true">
784 <dataset name="data" source="detector" det_default="true">
785 <attribute name="signal" source="constant" value="1" type="int" />
786 </dataset>
787 <dataset name="exposure" source="ndattribute" ndattribute="AcquireTime" type="float" />
788 </group>
789 <group name="instrument">
790 <dataset name="name" source="constant" value="MyBeamline" type="string" />
791 <hardlink name="link_to_data" target="/entry/data/data" />
792 </group>
793 </group>
794</hdf5_layout>"#;
795
796 #[test]
797 fn parses_full_tree() {
798 let layout = Hdf5Layout::parse(SAMPLE).unwrap();
799 assert_eq!(layout.groups.len(), 1);
800 assert_eq!(layout.detector_data_destination.as_deref(), Some("detdest"));
801 let entry = &layout.groups[0];
802 assert_eq!(entry.name, "entry");
803 assert_eq!(entry.attributes.len(), 1);
804 assert_eq!(entry.attributes[0].name, "NX_class");
805 assert_eq!(entry.attributes[0].value, "NXentry");
806 assert_eq!(entry.groups.len(), 2);
807
808 let data_group = &entry.groups[0];
809 assert!(data_group.ndattr_default);
810 assert_eq!(data_group.datasets.len(), 2);
811 assert!(data_group.datasets[0].det_default);
812 assert_eq!(data_group.datasets[0].source, LayoutSource::Detector);
813 assert_eq!(data_group.datasets[0].attributes.len(), 1);
814 assert_eq!(
815 data_group.datasets[0].attributes[0].data_type,
816 LayoutDataType::Int
817 );
818 assert_eq!(data_group.datasets[1].source, LayoutSource::NdAttribute);
819 assert_eq!(data_group.datasets[1].ndattribute, "AcquireTime");
820 assert_eq!(data_group.datasets[1].data_type, LayoutDataType::Float);
821
822 let instr = &entry.groups[1];
823 assert_eq!(instr.hardlinks.len(), 1);
824 assert_eq!(instr.hardlinks[0].target, "/entry/data/data");
825 }
826
827 #[test]
828 fn detector_path_resolves() {
829 let layout = Hdf5Layout::parse(SAMPLE).unwrap();
830 assert_eq!(
831 layout.detector_dataset_path().as_deref(),
832 Some("/entry/data/data")
833 );
834 assert_eq!(
835 layout.ndattr_default_group().as_deref(),
836 Some("/entry/data")
837 );
838 }
839
840 #[test]
841 fn rejects_missing_root() {
842 let err = Hdf5Layout::parse("<foo/>").unwrap_err();
843 assert!(err.0.contains("hdf5_layout"));
844 }
845
846 #[test]
847 fn rejects_missing_dataset_name() {
848 let xml =
849 r#"<hdf5_layout><group name="g"><dataset source="detector"/></group></hdf5_layout>"#;
850 let err = Hdf5Layout::parse(xml).unwrap_err();
851 assert!(err.0.contains("name"));
852 }
853
854 #[test]
855 fn rejects_bad_source() {
856 let xml = r#"<hdf5_layout><group name="g"><dataset name="d" source="bogus"/></group></hdf5_layout>"#;
857 let err = Hdf5Layout::parse(xml).unwrap_err();
858 assert!(err.0.contains("bogus"));
859 }
860
861 #[test]
862 fn handles_comments_and_entities() {
863 let xml = r#"<hdf5_layout>
864 <!-- a comment -->
865 <group name="g">
866 <dataset name="d" source="constant" value="a & b" type="string"/>
867 </group>
868 </hdf5_layout>"#;
869 let layout = Hdf5Layout::parse(xml).unwrap();
870 assert_eq!(layout.groups[0].datasets[0].value, "a & b");
871 }
872}