document_svg/document/
vcf.rs1use 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_VCF_BYTES: u64 = 128 * 1024 * 1024;
14const MAX_VCF_LINES: usize = 2_000_000;
15const MAX_VCF_LINE_BYTES: usize = 1 << 20;
16const MAX_VCF_VARIANTS: usize = 100_000;
17const MAX_VCF_COLUMNS: usize = 256;
18const MAX_VCF_VALUE_BYTES: usize = 64 * 1024;
19const MAX_VCF_CELLS: usize = 2_000_000;
20
21pub(crate) fn looks_like_prefix(prefix: &[u8]) -> bool {
22 let Ok(text) = std::str::from_utf8(prefix) else {
23 return false;
24 };
25 text.lines().any(|line| {
26 let line = line.trim_start();
27 line.to_ascii_lowercase().starts_with("##fileformat=vcf") || line.starts_with("#CHROM\t")
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_VCF_BYTES),
39 "VCF input",
40 )?;
41 let text = String::from_utf8(bytes)
42 .map_err(|error| Error::InvalidInput(format!("VCF input must be UTF-8/ASCII: {error}")))?;
43 let (mut table, warnings) = parse_vcf(&text)?;
44 let mut page_sink = VcfPageSink {
45 inner: sink,
46 warnings: &warnings,
47 };
48 convert_table_pages(&mut table, "vcf", options, &mut page_sink)?;
49 Ok(warnings)
50}
51
52struct VcfPageSink<'a> {
53 inner: &'a mut dyn PageConsumer,
54 warnings: &'a [String],
55}
56impl PageConsumer for VcfPageSink<'_> {
57 fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
58 page.source_format = "vcf".into();
59 page.title = "VCF variant annotations".into();
60 page.description = "Variant and sample fields are displayed inertly; no reference or genotype analysis is performed".into();
61 for warning in self.warnings {
62 page.warn(warning.clone());
63 }
64 self.inner.consume(page)
65 }
66}
67
68fn parse_vcf(text: &str) -> Result<(TableData, Vec<String>)> {
69 if text.len() as u64 > MAX_VCF_BYTES {
70 return Err(Error::LimitExceeded(format!(
71 "VCF input exceeds {MAX_VCF_BYTES} bytes"
72 )));
73 }
74 let lines = text.lines().collect::<Vec<_>>();
75 if lines.len() > MAX_VCF_LINES {
76 return Err(Error::LimitExceeded(format!(
77 "VCF input exceeds {MAX_VCF_LINES} lines"
78 )));
79 }
80 let mut headers = None::<Vec<String>>;
81 let mut rows = Vec::new();
82 let mut warnings = Vec::new();
83 let mut metadata_seen = false;
84 let mut fileformat_seen = false;
85 let mut embedded_fasta = false;
86 let mut cells = 0usize;
87 for (line_number, original) in lines.iter().enumerate() {
88 if original.len() > MAX_VCF_LINE_BYTES {
89 return Err(Error::LimitExceeded(format!(
90 "VCF line {} exceeds {MAX_VCF_LINE_BYTES} bytes",
91 line_number + 1
92 )));
93 }
94 let line = original.trim_end_matches('\r');
95 if line.is_empty() {
96 continue;
97 }
98 if line.starts_with("##FASTA") {
99 embedded_fasta = true;
100 break;
101 }
102 if line.starts_with("##") {
103 metadata_seen = true;
104 if line.to_ascii_lowercase().starts_with("##fileformat=vcf") {
105 fileformat_seen = true;
106 }
107 continue;
108 }
109 if line.starts_with('#') {
110 if headers.is_some() {
111 return Err(Error::InvalidInput(format!(
112 "VCF has duplicate column header at line {}",
113 line_number + 1
114 )));
115 }
116 let fields = line.split('\t').collect::<Vec<_>>();
117 if fields.len() < 8
118 || fields.len() > MAX_VCF_COLUMNS
119 || fields[..8]
120 != [
121 "#CHROM", "POS", "ID", "REF", "ALT", "QUAL", "FILTER", "INFO",
122 ]
123 {
124 return Err(Error::InvalidInput(format!(
125 "VCF column header at line {} is invalid",
126 line_number + 1
127 )));
128 }
129 headers = Some(fields.into_iter().map(str::to_owned).collect());
130 continue;
131 }
132 let header = headers
133 .as_ref()
134 .ok_or_else(|| Error::InvalidInput("VCF data appears before #CHROM header".into()))?;
135 let fields = line.split('\t').collect::<Vec<_>>();
136 if fields.len() != header.len() {
137 return Err(Error::InvalidInput(format!(
138 "VCF line {} has {} columns; expected {}",
139 line_number + 1,
140 fields.len(),
141 header.len()
142 )));
143 }
144 if rows.len() >= MAX_VCF_VARIANTS {
145 return Err(Error::LimitExceeded(format!(
146 "VCF exceeds {MAX_VCF_VARIANTS} variants"
147 )));
148 }
149 if fields[0].is_empty()
150 || fields[2].is_empty()
151 || fields[3].is_empty()
152 || fields[4].is_empty()
153 {
154 return Err(Error::InvalidInput(format!(
155 "VCF line {} has an empty required field",
156 line_number + 1
157 )));
158 }
159 let pos = fields[1].parse::<u64>().map_err(|_| {
160 Error::InvalidInput(format!("VCF line {} POS is invalid", line_number + 1))
161 })?;
162 if pos == 0 {
163 return Err(Error::InvalidInput(format!(
164 "VCF line {} POS must be 1-based",
165 line_number + 1
166 )));
167 }
168 if fields[5] != "." {
169 let qual = fields[5].parse::<f64>().map_err(|_| {
170 Error::InvalidInput(format!("VCF line {} QUAL is invalid", line_number + 1))
171 })?;
172 if !qual.is_finite() {
173 return Err(Error::InvalidInput("VCF QUAL is non-finite".into()));
174 }
175 }
176 for field in &fields {
177 if field.len() > MAX_VCF_VALUE_BYTES {
178 return Err(Error::LimitExceeded(format!(
179 "VCF line {} field exceeds {MAX_VCF_VALUE_BYTES} bytes",
180 line_number + 1
181 )));
182 }
183 if field.chars().any(char::is_control) {
184 return Err(Error::InvalidInput(format!(
185 "VCF line {} contains a control character",
186 line_number + 1
187 )));
188 }
189 }
190 cells = cells
191 .checked_add(fields.len())
192 .ok_or_else(|| Error::LimitExceeded("VCF cell count overflowed".into()))?;
193 if cells > MAX_VCF_CELLS {
194 return Err(Error::LimitExceeded(format!(
195 "VCF exceeds {MAX_VCF_CELLS} cells"
196 )));
197 }
198 rows.push(fields.into_iter().map(str::to_owned).collect());
199 }
200 let headers =
201 headers.ok_or_else(|| Error::InvalidInput("VCF #CHROM header is missing".into()))?;
202 if rows.is_empty() {
203 return Err(Error::InvalidInput("VCF contains no variant rows".into()));
204 }
205 if metadata_seen {
206 warnings.push(
207 "VCF metadata directives were ignored; reference URLs and descriptions were not loaded"
208 .into(),
209 );
210 }
211 if embedded_fasta {
212 warnings.push("embedded VCF ##FASTA sequence data was omitted".into());
213 }
214 if !fileformat_seen {
215 warnings
216 .push("VCF ##fileformat directive was missing; the tabular header was accepted".into());
217 }
218 let alignments = (0..headers.len())
219 .map(|index| {
220 if matches!(index, 1 | 5) {
221 TableAlign::Right
222 } else {
223 TableAlign::Left
224 }
225 })
226 .collect();
227 Ok((
228 TableData {
229 headers,
230 rows,
231 alignments,
232 raw_source: String::new(),
233 },
234 warnings,
235 ))
236}