Skip to main content

document_svg/document/
bed.rs

1//! Bounded UCSC BED and bedGraph interval-table preview.
2//!
3//! Coordinates remain inert genome intervals; no reference genome, remote
4//! track, URL, or sequence is resolved. BED12 block lists are checked for
5//! consistency while bedGraph values are kept as plain numeric text.
6
7use std::path::Path;
8
9use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
10use crate::error::{Error, Result};
11use crate::table::{TableAlign, TableData, convert_table_pages};
12
13const MAX_BED_BYTES: u64 = 128 * 1024 * 1024;
14const MAX_BED_LINES: usize = 2_000_000;
15const MAX_BED_LINE_BYTES: usize = 1 << 20;
16const MAX_BED_FEATURES: usize = 100_000;
17const MAX_BED_COLUMNS: usize = 12;
18const MAX_BED_CELLS: usize = 1_200_000;
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub(crate) enum BedFormat {
22    Bed,
23    BedGraph,
24}
25
26pub(crate) fn looks_like_bedgraph_prefix(prefix: &[u8]) -> bool {
27    let Ok(text) = std::str::from_utf8(prefix) else {
28        return false;
29    };
30    let lower = text.to_ascii_lowercase();
31    if lower
32        .lines()
33        .any(|line| line.trim_start().starts_with("track") && line.contains("type=bedgraph"))
34    {
35        return true;
36    }
37    first_interval_line(text)
38        .is_some_and(|fields| fields.len() == 4 && fields[3].parse::<f64>().is_ok())
39}
40
41pub(crate) fn looks_like_bed_prefix(prefix: &[u8]) -> bool {
42    let Ok(text) = std::str::from_utf8(prefix) else {
43        return false;
44    };
45    first_interval_line(text).is_some_and(|fields| {
46        fields.len() >= 3
47            && fields.len() <= 12
48            && fields[1].parse::<u64>().is_ok()
49            && fields[2].parse::<u64>().is_ok()
50    })
51}
52
53fn first_interval_line(text: &str) -> Option<Vec<&str>> {
54    text.lines().map(str::trim).find_map(|line| {
55        if line.is_empty()
56            || line.starts_with('#')
57            || line.starts_with("track")
58            || line.starts_with("browser")
59        {
60            return None;
61        }
62        let fields = line.split_whitespace().collect::<Vec<_>>();
63        if fields.len() >= 3
64            && fields.first().is_some_and(|field| {
65                field.starts_with("chr")
66                    || field.starts_with("scaffold")
67                    || field.starts_with("contig")
68                    || field.starts_with("NC_")
69            })
70        {
71            Some(fields)
72        } else {
73            None
74        }
75    })
76}
77
78pub(crate) fn convert(
79    path: &Path,
80    options: &ConvertOptions,
81    sink: &mut dyn PageConsumer,
82    format: BedFormat,
83) -> Result<Vec<String>> {
84    let bytes = read_limited_file(
85        path,
86        options.max_input_bytes.min(MAX_BED_BYTES),
87        "BED input",
88    )?;
89    let text = String::from_utf8(bytes)
90        .map_err(|error| Error::InvalidInput(format!("BED input must be UTF-8/ASCII: {error}")))?;
91    let (mut table, warnings) = parse(&text, format)?;
92    let format_name = match format {
93        BedFormat::Bed => "bed",
94        BedFormat::BedGraph => "bedgraph",
95    };
96    let mut page_sink = BedPageSink {
97        inner: sink,
98        format_name,
99        warnings: &warnings,
100    };
101    convert_table_pages(&mut table, format_name, options, &mut page_sink)?;
102    Ok(warnings)
103}
104
105struct BedPageSink<'a> {
106    inner: &'a mut dyn PageConsumer,
107    format_name: &'static str,
108    warnings: &'a [String],
109}
110impl PageConsumer for BedPageSink<'_> {
111    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
112        page.source_format = self.format_name.into();
113        page.title = format!(
114            "{} interval annotations",
115            self.format_name.to_ascii_uppercase()
116        );
117        page.description =
118            "Genome intervals and values are displayed inertly; no reference data is loaded".into();
119        for warning in self.warnings {
120            page.warn(warning.clone());
121        }
122        self.inner.consume(page)
123    }
124}
125
126fn parse(text: &str, format: BedFormat) -> Result<(TableData, Vec<String>)> {
127    let lines = text.lines().collect::<Vec<_>>();
128    if lines.len() > MAX_BED_LINES {
129        return Err(Error::LimitExceeded(format!(
130            "BED input exceeds {MAX_BED_LINES} lines"
131        )));
132    }
133    let mut rows = Vec::new();
134    let mut warnings = Vec::new();
135    let mut directives = false;
136    let mut cells = 0usize;
137    for (line_number, original) in lines.iter().enumerate() {
138        if original.len() > MAX_BED_LINE_BYTES {
139            return Err(Error::LimitExceeded(format!(
140                "BED line {} exceeds {MAX_BED_LINE_BYTES} bytes",
141                line_number + 1
142            )));
143        }
144        let line = original.trim();
145        if line.is_empty() || line.starts_with('#') {
146            continue;
147        }
148        if line.starts_with("track") || line.starts_with("browser") {
149            directives = true;
150            continue;
151        }
152        let fields = line.split_whitespace().collect::<Vec<_>>();
153        match format {
154            BedFormat::BedGraph => {
155                if fields.len() != 4 {
156                    return Err(Error::InvalidInput(format!(
157                        "bedGraph line {} must contain four columns",
158                        line_number + 1
159                    )));
160                }
161                let (start, end) =
162                    validate_interval(fields[0], fields[1], fields[2], line_number + 1)?;
163                let value = fields[3].parse::<f64>().map_err(|_| {
164                    Error::InvalidInput(format!(
165                        "bedGraph line {} has invalid dataValue",
166                        line_number + 1
167                    ))
168                })?;
169                if !value.is_finite() {
170                    return Err(Error::InvalidInput(
171                        "bedGraph dataValue is non-finite".into(),
172                    ));
173                }
174                let _ = (start, end);
175                rows.push(fields.into_iter().map(str::to_owned).collect::<Vec<_>>());
176            }
177            BedFormat::Bed => {
178                if fields.len() < 3 || fields.len() > MAX_BED_COLUMNS {
179                    return Err(Error::InvalidInput(format!(
180                        "BED line {} must contain 3–12 columns",
181                        line_number + 1
182                    )));
183                }
184                let (start, end) =
185                    validate_interval(fields[0], fields[1], fields[2], line_number + 1)?;
186                if let Some(score) = fields.get(4)
187                    && *score != "."
188                {
189                    let value = score.parse::<u16>().map_err(|_| {
190                        Error::InvalidInput(format!(
191                            "BED line {} has invalid score",
192                            line_number + 1
193                        ))
194                    })?;
195                    if value > 1000 {
196                        return Err(Error::InvalidInput(format!(
197                            "BED line {} score exceeds 1000",
198                            line_number + 1
199                        )));
200                    }
201                }
202                if let Some(strand) = fields.get(5)
203                    && !matches!(*strand, "+" | "-" | "." | "?")
204                {
205                    return Err(Error::InvalidInput(format!(
206                        "BED line {} has invalid strand",
207                        line_number + 1
208                    )));
209                }
210                for index in [6usize, 7] {
211                    if let Some(value) = fields.get(index)
212                        && *value != "."
213                    {
214                        let _ = parse_u64(value, "BED thick coordinate", line_number + 1)?;
215                    }
216                }
217                if let Some(rgb) = fields.get(8)
218                    && *rgb != "."
219                {
220                    validate_rgb(rgb, line_number + 1)?;
221                }
222                if fields.len() >= 12 {
223                    let count = parse_u64(fields[9], "BED blockCount", line_number + 1)? as usize;
224                    let sizes = parse_list(fields[10], "BED blockSizes", line_number + 1)?;
225                    let starts = parse_list(fields[11], "BED blockStarts", line_number + 1)?;
226                    if count == 0 || count != sizes.len() || count != starts.len() {
227                        return Err(Error::InvalidInput(format!(
228                            "BED line {} blockCount does not match block lists",
229                            line_number + 1
230                        )));
231                    }
232                    if starts.first().copied() != Some(0) {
233                        return Err(Error::InvalidInput(format!(
234                            "BED line {} first blockStart must be 0",
235                            line_number + 1
236                        )));
237                    }
238                    let interval_span =
239                        usize::try_from(end.saturating_sub(start)).map_err(|_| {
240                            Error::LimitExceeded(format!(
241                                "BED line {} interval exceeds address space",
242                                line_number + 1
243                            ))
244                        })?;
245                    for (block_start, block_size) in starts.iter().zip(&sizes) {
246                        let block_end = block_start.checked_add(*block_size).ok_or_else(|| {
247                            Error::LimitExceeded("BED block coordinate overflowed".into())
248                        })?;
249                        if block_end > interval_span {
250                            return Err(Error::InvalidInput(format!(
251                                "BED line {} block exceeds interval",
252                                line_number + 1
253                            )));
254                        }
255                        if *block_size == 0 {
256                            return Err(Error::InvalidInput(format!(
257                                "BED line {} block has zero size",
258                                line_number + 1
259                            )));
260                        }
261                    }
262                }
263                rows.push(fields.into_iter().map(str::to_owned).collect::<Vec<_>>());
264            }
265        }
266        if rows.len() > MAX_BED_FEATURES {
267            return Err(Error::LimitExceeded(format!(
268                "BED input exceeds {MAX_BED_FEATURES} features"
269            )));
270        }
271        cells = cells
272            .checked_add(rows.last().map(Vec::len).unwrap_or(0))
273            .ok_or_else(|| Error::LimitExceeded("BED cell count overflowed".into()))?;
274        if cells > MAX_BED_CELLS {
275            return Err(Error::LimitExceeded(format!(
276                "BED input exceeds {MAX_BED_CELLS} cells"
277            )));
278        }
279    }
280    if rows.is_empty() {
281        return Err(Error::InvalidInput(
282            "BED input contains no interval rows".into(),
283        ));
284    }
285    if directives {
286        warnings.push("BED track/browser directives were ignored".into());
287    }
288    let headers = match format {
289        BedFormat::BedGraph => vec!["chrom", "chromStart", "chromEnd", "dataValue"],
290        BedFormat::Bed => vec![
291            "chrom",
292            "chromStart",
293            "chromEnd",
294            "name",
295            "score",
296            "strand",
297            "thickStart",
298            "thickEnd",
299            "itemRgb",
300            "blockCount",
301            "blockSizes",
302            "blockStarts",
303        ],
304    };
305    let alignments = (0..headers.len())
306        .map(|index| {
307            if matches!(index, 1 | 2 | 4 | 6 | 7 | 9) {
308                TableAlign::Right
309            } else {
310                TableAlign::Left
311            }
312        })
313        .collect();
314    Ok((
315        TableData {
316            headers: headers.into_iter().map(str::to_owned).collect(),
317            rows,
318            alignments,
319            raw_source: String::new(),
320        },
321        warnings,
322    ))
323}
324
325fn validate_interval(chrom: &str, start: &str, end: &str, line: usize) -> Result<(u64, u64)> {
326    if chrom.is_empty() || chrom.contains('\t') {
327        return Err(Error::InvalidInput(format!(
328            "BED line {line} chromosome is empty"
329        )));
330    }
331    let start = start
332        .parse::<u64>()
333        .map_err(|_| Error::InvalidInput(format!("BED line {line} chromStart is invalid")))?;
334    let end = end
335        .parse::<u64>()
336        .map_err(|_| Error::InvalidInput(format!("BED line {line} chromEnd is invalid")))?;
337    if end < start {
338        return Err(Error::InvalidInput(format!(
339            "BED line {line} chromEnd precedes chromStart"
340        )));
341    }
342    Ok((start, end))
343}
344fn parse_u64(value: &str, context: &str, line: usize) -> Result<u64> {
345    value
346        .parse::<u64>()
347        .map_err(|_| Error::InvalidInput(format!("{context} on line {line} is invalid")))
348}
349fn parse_list(value: &str, context: &str, line: usize) -> Result<Vec<usize>> {
350    value
351        .trim_end_matches(',')
352        .split(',')
353        .filter(|part| !part.is_empty())
354        .map(|part| {
355            parse_u64(part, context, line).and_then(|number| {
356                usize::try_from(number).map_err(|_| {
357                    Error::LimitExceeded(format!("{context} on line {line} exceeds address space"))
358                })
359            })
360        })
361        .collect()
362}
363fn validate_rgb(value: &str, line: usize) -> Result<()> {
364    let fields = value.split(',').collect::<Vec<_>>();
365    if fields.len() != 3 {
366        return Err(Error::InvalidInput(format!(
367            "BED line {line} itemRgb is invalid"
368        )));
369    }
370    for field in fields {
371        if field.parse::<u16>().ok().is_none_or(|number| number > 255) {
372            return Err(Error::InvalidInput(format!(
373                "BED line {line} itemRgb is outside 0..255"
374            )));
375        }
376    }
377    Ok(())
378}