Skip to main content

document_svg/document/
wig.rs

1//! Bounded UCSC Wiggle (`.wig`) continuous-signal preview.
2//!
3//! The fixedStep and variableStep text forms are converted into inert interval
4//! rows. Coordinates remain one-based and fully closed as defined by UCSC;
5//! track/browser directives and remote track references are never evaluated.
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_WIG_BYTES: u64 = 128 * 1024 * 1024;
14const MAX_WIG_LINES: usize = 2_000_000;
15const MAX_WIG_LINE_BYTES: usize = 1 << 20;
16const MAX_WIG_VALUES: usize = 100_000;
17const MAX_WIG_VALUE_BYTES: usize = 64 * 1024;
18
19pub(crate) fn looks_like_prefix(prefix: &[u8]) -> bool {
20    let Ok(text) = std::str::from_utf8(prefix) else {
21        return false;
22    };
23    text.lines().map(str::trim).any(|line| {
24        let lower = line.to_ascii_lowercase();
25        lower.starts_with("fixedstep ")
26            || lower.starts_with("variablestep ")
27            || lower.contains("type=wiggle_0")
28    })
29}
30
31pub(crate) fn convert(
32    path: &Path,
33    options: &ConvertOptions,
34    sink: &mut dyn PageConsumer,
35) -> Result<Vec<String>> {
36    let bytes = read_limited_file(
37        path,
38        options.max_input_bytes.min(MAX_WIG_BYTES),
39        "WIG input",
40    )?;
41    let text = String::from_utf8(bytes)
42        .map_err(|error| Error::InvalidInput(format!("WIG input must be UTF-8/ASCII: {error}")))?;
43    let (mut table, warnings) = parse_wig(&text)?;
44    let mut page_sink = WigPageSink {
45        inner: sink,
46        warnings: &warnings,
47    };
48    convert_table_pages(&mut table, "wig", options, &mut page_sink)?;
49    Ok(warnings)
50}
51
52struct WigPageSink<'a> {
53    inner: &'a mut dyn PageConsumer,
54    warnings: &'a [String],
55}
56impl PageConsumer for WigPageSink<'_> {
57    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
58        page.source_format = "wig".into();
59        page.title = "WIG continuous signal".into();
60        page.description =
61            "UCSC fixedStep/variableStep values are displayed as inert one-based closed intervals"
62                .into();
63        for warning in self.warnings {
64            page.warn(warning.clone());
65        }
66        self.inner.consume(page)
67    }
68}
69
70#[derive(Clone, Debug)]
71enum Mode {
72    Fixed {
73        chrom: String,
74        next_start: u64,
75        step: u64,
76        span: u64,
77    },
78    Variable {
79        chrom: String,
80        span: u64,
81        previous_start: Option<u64>,
82    },
83}
84
85fn parse_wig(text: &str) -> Result<(TableData, Vec<String>)> {
86    if text.len() as u64 > MAX_WIG_BYTES {
87        return Err(Error::LimitExceeded(format!(
88            "WIG input exceeds {MAX_WIG_BYTES} bytes"
89        )));
90    }
91    let lines = text.lines().collect::<Vec<_>>();
92    if lines.len() > MAX_WIG_LINES {
93        return Err(Error::LimitExceeded(format!(
94            "WIG input exceeds {MAX_WIG_LINES} lines"
95        )));
96    }
97    let mut rows = Vec::new();
98    let mut warnings = Vec::new();
99    let mut mode = None::<Mode>;
100    let mut directives = false;
101    for (line_number, original) in lines.iter().enumerate() {
102        if original.len() > MAX_WIG_LINE_BYTES {
103            return Err(Error::LimitExceeded(format!(
104                "WIG line {} exceeds {MAX_WIG_LINE_BYTES} bytes",
105                line_number + 1
106            )));
107        }
108        let line = original.trim();
109        if line.is_empty() || line.starts_with('#') {
110            continue;
111        }
112        let lower = line.to_ascii_lowercase();
113        if lower.starts_with("track") || lower.starts_with("browser") {
114            directives = true;
115            continue;
116        }
117        if lower.starts_with("fixedstep") {
118            let fields = parse_declaration(line, "fixedStep")?;
119            let chrom = required_field(&fields, "chrom")?;
120            let start = parse_positive(&fields, "start")?;
121            let step = parse_positive_or_default(&fields, "step", 1)?;
122            let span = parse_positive_or_default(&fields, "span", 1)?;
123            mode = Some(Mode::Fixed {
124                chrom,
125                next_start: start,
126                step,
127                span,
128            });
129            continue;
130        }
131        if lower.starts_with("variablestep") {
132            let fields = parse_declaration(line, "variableStep")?;
133            let chrom = required_field(&fields, "chrom")?;
134            let span = parse_positive_or_default(&fields, "span", 1)?;
135            mode = Some(Mode::Variable {
136                chrom,
137                span,
138                previous_start: None,
139            });
140            continue;
141        }
142        let active = mode.as_mut().ok_or_else(|| {
143            Error::InvalidInput(format!(
144                "WIG data line {} appears before fixedStep/variableStep declaration",
145                line_number + 1
146            ))
147        })?;
148        match active {
149            Mode::Fixed {
150                chrom,
151                next_start,
152                step,
153                span,
154            } => {
155                for token in line.split_ascii_whitespace() {
156                    let value = parse_signal(token, line_number + 1)?;
157                    let start = *next_start;
158                    let end = start.checked_add(*span - 1).ok_or_else(|| {
159                        Error::LimitExceeded("WIG fixedStep coordinate overflowed".into())
160                    })?;
161                    rows.push(vec![
162                        chrom.clone(),
163                        start.to_string(),
164                        end.to_string(),
165                        value.to_string(),
166                    ]);
167                    *next_start = start.checked_add(*step).ok_or_else(|| {
168                        Error::LimitExceeded("WIG fixedStep step overflowed".into())
169                    })?;
170                    if rows.len() > MAX_WIG_VALUES {
171                        return Err(Error::LimitExceeded(format!(
172                            "WIG exceeds {MAX_WIG_VALUES} values"
173                        )));
174                    }
175                }
176            }
177            Mode::Variable {
178                chrom,
179                span,
180                previous_start,
181            } => {
182                let tokens = line.split_ascii_whitespace().collect::<Vec<_>>();
183                if tokens.len() != 2 {
184                    return Err(Error::InvalidInput(format!(
185                        "WIG variableStep line {} must contain position and value",
186                        line_number + 1
187                    )));
188                }
189                let start = tokens[0].parse::<u64>().map_err(|_| {
190                    Error::InvalidInput(format!("WIG line {} position is invalid", line_number + 1))
191                })?;
192                if start == 0 {
193                    return Err(Error::InvalidInput(format!(
194                        "WIG line {} position must be one-based",
195                        line_number + 1
196                    )));
197                }
198                if previous_start.is_some_and(|previous| start <= previous) {
199                    return Err(Error::InvalidInput(format!(
200                        "WIG line {} variableStep positions are not increasing",
201                        line_number + 1
202                    )));
203                }
204                let end = start.checked_add(*span - 1).ok_or_else(|| {
205                    Error::LimitExceeded("WIG variableStep coordinate overflowed".into())
206                })?;
207                let value = parse_signal(tokens[1], line_number + 1)?;
208                rows.push(vec![
209                    chrom.clone(),
210                    start.to_string(),
211                    end.to_string(),
212                    value.to_string(),
213                ]);
214                *previous_start = Some(start);
215                if rows.len() > MAX_WIG_VALUES {
216                    return Err(Error::LimitExceeded(format!(
217                        "WIG exceeds {MAX_WIG_VALUES} values"
218                    )));
219                }
220            }
221        }
222    }
223    if rows.is_empty() {
224        return Err(Error::InvalidInput("WIG contains no signal values".into()));
225    }
226    if directives {
227        warnings.push("WIG track/browser directives were ignored".into());
228    }
229    let headers = ["chrom", "chromStart", "chromEnd", "dataValue"]
230        .into_iter()
231        .map(str::to_owned)
232        .collect();
233    let alignments = vec![
234        TableAlign::Left,
235        TableAlign::Right,
236        TableAlign::Right,
237        TableAlign::Right,
238    ];
239    Ok((
240        TableData {
241            headers,
242            rows,
243            alignments,
244            raw_source: String::new(),
245        },
246        warnings,
247    ))
248}
249
250fn parse_declaration(
251    line: &str,
252    keyword: &str,
253) -> Result<std::collections::HashMap<String, String>> {
254    let mut parts = line.split_ascii_whitespace();
255    let found = parts.next().unwrap_or_default();
256    if !found.eq_ignore_ascii_case(keyword) {
257        return Err(Error::InvalidInput(format!(
258            "WIG declaration {found:?} is invalid"
259        )));
260    }
261    let mut fields = std::collections::HashMap::new();
262    for token in parts {
263        let Some((key, value)) = token.split_once('=') else {
264            return Err(Error::InvalidInput(format!(
265                "WIG declaration token {token:?} is invalid"
266            )));
267        };
268        if key.is_empty() || value.is_empty() {
269            return Err(Error::InvalidInput(
270                "WIG declaration has an empty key/value".into(),
271            ));
272        }
273        fields.insert(key.to_ascii_lowercase(), value.trim_matches('"').to_owned());
274    }
275    Ok(fields)
276}
277
278fn required_field(fields: &std::collections::HashMap<String, String>, key: &str) -> Result<String> {
279    fields
280        .get(key)
281        .filter(|value| !value.is_empty())
282        .cloned()
283        .ok_or_else(|| Error::InvalidInput(format!("WIG declaration is missing {key}")))
284}
285fn parse_positive(fields: &std::collections::HashMap<String, String>, key: &str) -> Result<u64> {
286    let value = fields
287        .get(key)
288        .ok_or_else(|| Error::InvalidInput(format!("WIG declaration is missing {key}")))?;
289    let value = value
290        .parse::<u64>()
291        .map_err(|_| Error::InvalidInput(format!("WIG {key} is invalid")))?;
292    if value == 0 {
293        return Err(Error::InvalidInput(format!("WIG {key} must be positive")));
294    }
295    Ok(value)
296}
297fn parse_positive_or_default(
298    fields: &std::collections::HashMap<String, String>,
299    key: &str,
300    default: u64,
301) -> Result<u64> {
302    match fields.get(key) {
303        None => Ok(default),
304        Some(value) => {
305            let value = value
306                .parse::<u64>()
307                .map_err(|_| Error::InvalidInput(format!("WIG {key} is invalid")))?;
308            if value == 0 {
309                return Err(Error::InvalidInput(format!("WIG {key} must be positive")));
310            }
311            Ok(value)
312        }
313    }
314}
315fn parse_signal(value: &str, line: usize) -> Result<f64> {
316    if value.len() > MAX_WIG_VALUE_BYTES {
317        return Err(Error::LimitExceeded(format!(
318            "WIG line {line} value exceeds {MAX_WIG_VALUE_BYTES} bytes"
319        )));
320    }
321    let value = value
322        .parse::<f64>()
323        .map_err(|_| Error::InvalidInput(format!("WIG line {line} signal value is invalid")))?;
324    if !value.is_finite() {
325        return Err(Error::InvalidInput(format!(
326            "WIG line {line} signal is non-finite"
327        )));
328    }
329    Ok(value)
330}