Skip to main content

document_svg/document/
gff.rs

1//! Bounded GFF3/GTF genome-feature annotation preview.
2//!
3//! The nine tab-separated feature columns are rendered as an inert table. A
4//! `##FASTA` tail, directives, and attribute links remain text/diagnostics; no
5//! sequence analysis, URL fetch, or feature hierarchy evaluation is performed.
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_GFF_BYTES: u64 = 128 * 1024 * 1024;
14const MAX_GFF_LINES: usize = 5_000_000;
15const MAX_GFF_LINE_BYTES: usize = 1 << 20;
16const MAX_GFF_FEATURES: usize = 100_000;
17const MAX_GFF_ATTRIBUTES: usize = 256;
18const MAX_GFF_ATTRIBUTE_BYTES: usize = 64 * 1024;
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub(crate) enum FeatureFormat {
22    Gff3,
23    Gtf,
24}
25
26pub(crate) fn looks_like_gff3_prefix(prefix: &[u8]) -> bool {
27    let Ok(text) = std::str::from_utf8(prefix) else {
28        return false;
29    };
30    text.lines()
31        .map(str::trim)
32        .find(|line| !line.is_empty())
33        .is_some_and(|line| line.to_ascii_lowercase().starts_with("##gff-version"))
34}
35
36pub(crate) fn looks_like_gtf_prefix(prefix: &[u8]) -> bool {
37    looks_like_feature_prefix(prefix, true)
38}
39
40pub(crate) fn looks_like_feature_prefix(prefix: &[u8], gtf: bool) -> bool {
41    let Ok(text) = std::str::from_utf8(prefix) else {
42        return false;
43    };
44    for line in text
45        .lines()
46        .map(str::trim)
47        .filter(|line| !line.is_empty() && !line.starts_with('#'))
48    {
49        let fields = line.split('\t').collect::<Vec<_>>();
50        if fields.len() != 9 {
51            continue;
52        }
53        if fields[0].is_empty() || fields[2].is_empty() || fields[8].is_empty() {
54            continue;
55        }
56        if fields[3].parse::<usize>().is_err() || fields[4].parse::<usize>().is_err() {
57            continue;
58        }
59        if gtf {
60            return fields[8].contains("gene_id") || fields[8].contains("transcript_id");
61        }
62        return true;
63    }
64    false
65}
66
67pub(crate) fn convert(
68    path: &Path,
69    options: &ConvertOptions,
70    sink: &mut dyn PageConsumer,
71    format: FeatureFormat,
72) -> Result<Vec<String>> {
73    let bytes = read_limited_file(
74        path,
75        options.max_input_bytes.min(MAX_GFF_BYTES),
76        "GFF/GTF input",
77    )?;
78    let text = String::from_utf8(bytes).map_err(|error| {
79        Error::InvalidInput(format!("GFF/GTF input must be UTF-8/ASCII: {error}"))
80    })?;
81    let (mut table, warnings) = parse(&text, format)?;
82    let format_name = match format {
83        FeatureFormat::Gff3 => "gff3",
84        FeatureFormat::Gtf => "gtf",
85    };
86    let mut page_sink = FeaturePageSink {
87        inner: sink,
88        format_name,
89        warnings: &warnings,
90    };
91    convert_table_pages(&mut table, format_name, options, &mut page_sink)?;
92    Ok(warnings)
93}
94
95struct FeaturePageSink<'a> {
96    inner: &'a mut dyn PageConsumer,
97    format_name: &'static str,
98    warnings: &'a [String],
99}
100
101impl PageConsumer for FeaturePageSink<'_> {
102    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
103        page.source_format = self.format_name.into();
104        page.title = format!(
105            "{} feature annotations",
106            self.format_name.to_ascii_uppercase()
107        );
108        page.description = "Genome feature coordinates and attributes are displayed inertly".into();
109        for warning in self.warnings {
110            page.warn(warning.clone());
111        }
112        self.inner.consume(page)
113    }
114}
115
116fn parse(text: &str, format: FeatureFormat) -> Result<(TableData, Vec<String>)> {
117    if text.len() as u64 > MAX_GFF_BYTES {
118        return Err(Error::LimitExceeded(format!(
119            "GFF/GTF input exceeds {MAX_GFF_BYTES} bytes"
120        )));
121    }
122    let mut rows = Vec::new();
123    let mut warnings = Vec::new();
124    let mut directives = false;
125    let mut embedded_fasta = false;
126    let mut seen_gff_version = false;
127    let mut features = 0usize;
128    for (line_number, original) in text.lines().enumerate() {
129        if line_number >= MAX_GFF_LINES {
130            return Err(Error::LimitExceeded(format!(
131                "GFF/GTF exceeds {MAX_GFF_LINES} lines"
132            )));
133        }
134        if original.len() > MAX_GFF_LINE_BYTES {
135            return Err(Error::LimitExceeded(format!(
136                "GFF/GTF line {} exceeds {MAX_GFF_LINE_BYTES} bytes",
137                line_number + 1
138            )));
139        }
140        let line = original.trim_end_matches('\r');
141        if line.trim().is_empty() {
142            continue;
143        }
144        if line.starts_with("##FASTA") {
145            embedded_fasta = true;
146            break;
147        }
148        if line.starts_with('#') {
149            directives = true;
150            if line.to_ascii_lowercase().starts_with("##gff-version") {
151                seen_gff_version = true;
152            }
153            continue;
154        }
155        let columns = line.split('\t').collect::<Vec<_>>();
156        if columns.len() != 9 {
157            return Err(Error::InvalidInput(format!(
158                "GFF/GTF feature line {} must contain 9 tab-separated columns",
159                line_number + 1
160            )));
161        }
162        if features >= MAX_GFF_FEATURES {
163            return Err(Error::LimitExceeded(format!(
164                "GFF/GTF exceeds {MAX_GFF_FEATURES} features"
165            )));
166        }
167        let seqid = columns[0];
168        let source = columns[1];
169        let feature_type = columns[2];
170        if seqid.is_empty() || source.is_empty() || feature_type.is_empty() {
171            return Err(Error::InvalidInput(format!(
172                "GFF/GTF feature line {} has an empty identity column",
173                line_number + 1
174            )));
175        }
176        let start = parse_coordinate(columns[3], "start", line_number + 1)?;
177        let end = parse_coordinate(columns[4], "end", line_number + 1)?;
178        if start == 0 || end == 0 || start > end {
179            return Err(Error::InvalidInput(format!(
180                "GFF/GTF feature line {} has invalid coordinate range",
181                line_number + 1
182            )));
183        }
184        let score = if columns[5] == "." {
185            ".".to_owned()
186        } else {
187            let value = columns[5].parse::<f64>().map_err(|_| {
188                Error::InvalidInput(format!(
189                    "GFF/GTF feature line {} has invalid score",
190                    line_number + 1
191                ))
192            })?;
193            if !value.is_finite() {
194                return Err(Error::InvalidInput("GFF/GTF score is non-finite".into()));
195            }
196            columns[5].to_owned()
197        };
198        if !matches!(columns[6], "+" | "-" | "." | "?") {
199            return Err(Error::InvalidInput(format!(
200                "GFF/GTF feature line {} has invalid strand",
201                line_number + 1
202            )));
203        }
204        if columns[7] != "." && !matches!(columns[7], "0" | "1" | "2") {
205            return Err(Error::InvalidInput(format!(
206                "GFF/GTF feature line {} has invalid phase",
207                line_number + 1
208            )));
209        }
210        let attributes = validate_attributes(columns[8], format)?;
211        rows.push(vec![
212            seqid.to_owned(),
213            source.to_owned(),
214            feature_type.to_owned(),
215            start.to_string(),
216            end.to_string(),
217            score,
218            columns[6].to_owned(),
219            columns[7].to_owned(),
220            attributes,
221        ]);
222        features += 1;
223    }
224    if features == 0 {
225        return Err(Error::InvalidInput(
226            "GFF/GTF contains no feature rows".into(),
227        ));
228    }
229    if directives {
230        warnings.push("GFF/GTF directives and comments were ignored".into());
231    }
232    if embedded_fasta {
233        warnings.push(
234            "embedded ##FASTA sequence data was omitted; only feature rows were rendered".into(),
235        );
236    }
237    if format == FeatureFormat::Gff3 && !seen_gff_version {
238        warnings.push(
239            "GFF3 version directive was missing; nine-column feature rows were accepted".into(),
240        );
241    }
242    let headers = [
243        "seqid",
244        "source",
245        "type",
246        "start",
247        "end",
248        "score",
249        "strand",
250        "phase",
251        "attributes",
252    ]
253    .into_iter()
254    .map(str::to_owned)
255    .collect();
256    let alignments = vec![
257        TableAlign::Left,
258        TableAlign::Left,
259        TableAlign::Left,
260        TableAlign::Right,
261        TableAlign::Right,
262        TableAlign::Right,
263        TableAlign::Center,
264        TableAlign::Center,
265        TableAlign::Left,
266    ];
267    Ok((
268        TableData {
269            headers,
270            rows,
271            alignments,
272            raw_source: String::new(),
273        },
274        warnings,
275    ))
276}
277
278fn parse_coordinate(value: &str, name: &str, line: usize) -> Result<usize> {
279    value.parse::<usize>().map_err(|_| {
280        Error::InvalidInput(format!("GFF/GTF line {line} has invalid {name} coordinate"))
281    })
282}
283
284fn validate_attributes(value: &str, format: FeatureFormat) -> Result<String> {
285    if value.len() > MAX_GFF_ATTRIBUTE_BYTES {
286        return Err(Error::LimitExceeded(format!(
287            "GFF/GTF attributes exceed {MAX_GFF_ATTRIBUTE_BYTES} bytes"
288        )));
289    }
290    if value == "." {
291        return Ok(String::new());
292    }
293    let pieces = value
294        .split(';')
295        .filter(|piece| !piece.trim().is_empty())
296        .collect::<Vec<_>>();
297    if pieces.len() > MAX_GFF_ATTRIBUTES {
298        return Err(Error::LimitExceeded(format!(
299            "GFF/GTF attributes exceed {MAX_GFF_ATTRIBUTES} entries"
300        )));
301    }
302    for piece in &pieces {
303        let valid = match format {
304            FeatureFormat::Gff3 => piece.contains('='),
305            FeatureFormat::Gtf => piece.split_whitespace().next().is_some_and(|key| {
306                let trimmed = piece.trim_start();
307                trimmed[key.len()..].trim_start().starts_with('"')
308                    || trimmed[key.len()..].trim_start().starts_with("'")
309            }),
310        };
311        if !valid {
312            return Err(Error::InvalidInput(
313                "GFF/GTF attribute entry is malformed".into(),
314            ));
315        }
316        if piece
317            .chars()
318            .any(|character| character.is_control() && character != '\t')
319        {
320            return Err(Error::InvalidInput(
321                "GFF/GTF attributes contain a control character".into(),
322            ));
323        }
324    }
325    Ok(value.to_owned())
326}