1use std::fs::File;
8use std::io::Read;
9use std::path::Path;
10
11use crate::convert::{ConvertOptions, PageConsumer};
12use crate::document::html::{HtmlBlock, render_blocks_to_pages};
13use crate::error::{Error, Result};
14use crate::ir::Page;
15
16const MAX_ICAL_BYTES: u64 = 64 * 1024 * 1024;
17const MAX_ICAL_PHYSICAL_LINES: usize = 1_000_000;
18const MAX_ICAL_LOGICAL_LINES: usize = 500_000;
19const MAX_ICAL_PROPERTIES_TOTAL: usize = 250_000;
20const MAX_ICAL_LINE_BYTES: usize = 1024 * 1024;
21const MAX_ICAL_COMPONENTS: usize = 100_000;
22const MAX_ICAL_PROPERTIES_PER_COMPONENT: usize = 20_000;
23const MAX_ICAL_PARAMS_PER_PROPERTY: usize = 100;
24const MAX_ICAL_TEXT_BYTES: usize = 32 * 1024 * 1024;
25const MAX_ICAL_COMPONENT_DEPTH: usize = 64;
26
27#[derive(Clone, Debug)]
28struct IcalProperty {
29 name: String,
30 tzid: Option<String>,
31 value: String,
32}
33
34#[derive(Clone, Debug)]
35struct IcalComponent {
36 kind: String,
37 properties: Vec<IcalProperty>,
38 has_alarm: bool,
39 has_recurrence: bool,
40 has_attachment: bool,
41 has_unknown_properties: bool,
42}
43
44struct IcalPageSink<'a> {
45 inner: &'a mut dyn PageConsumer,
46 source_format: &'static str,
47 page_offset: usize,
48 page_count: usize,
49 warnings: &'a [String],
50}
51
52impl PageConsumer for IcalPageSink<'_> {
53 fn consume(&mut self, mut page: Page) -> Result<()> {
54 page.number = page.number.saturating_add(self.page_offset);
55 page.source_format = self.source_format.into();
56 for warning in self.warnings {
57 page.warn(warning.clone());
58 }
59 self.inner.consume(page)?;
60 self.page_count = self.page_count.saturating_add(1);
61 Ok(())
62 }
63}
64
65pub(crate) fn convert(
66 path: &Path,
67 options: &ConvertOptions,
68 sink: &mut dyn PageConsumer,
69) -> Result<Vec<String>> {
70 convert_versioned(path, options, sink, "ical", "2.0")
71}
72
73pub(crate) fn convert_vcalendar(
74 path: &Path,
75 options: &ConvertOptions,
76 sink: &mut dyn PageConsumer,
77) -> Result<Vec<String>> {
78 convert_versioned(path, options, sink, "vcalendar", "1.0")
79}
80
81fn convert_versioned(
82 path: &Path,
83 options: &ConvertOptions,
84 sink: &mut dyn PageConsumer,
85 source_format: &'static str,
86 required_version: &str,
87) -> Result<Vec<String>> {
88 let max_bytes = options.max_input_bytes.min(MAX_ICAL_BYTES);
89 let mut bytes = Vec::new();
90 Read::take(File::open(path)?, max_bytes.saturating_add(1)).read_to_end(&mut bytes)?;
91 if bytes.len() as u64 > max_bytes {
92 return Err(Error::LimitExceeded(format!(
93 "calendar input exceeds maximum limit of {max_bytes} bytes"
94 )));
95 }
96 let (calendar_name, components, calendar_warnings) =
97 parse_calendar(&bytes, options.max_pages, required_version)?;
98 if components.is_empty() {
99 return Err(Error::InvalidInput(
100 "calendar file contains no VEVENT, VTODO, VJOURNAL, or VFREEBUSY components".into(),
101 ));
102 }
103 if components.len() > options.max_pages.min(MAX_ICAL_COMPONENTS) {
104 return Err(Error::LimitExceeded(format!(
105 "calendar file contains {} components; maximum is {} pages",
106 components.len(),
107 options.max_pages.min(MAX_ICAL_COMPONENTS)
108 )));
109 }
110
111 let mut warnings = calendar_warnings;
112 let mut total_pages = 0usize;
113 for component in components {
114 let (blocks, component_warnings) = render_component(&component, calendar_name.as_deref());
115 let remaining_pages = options.max_pages.saturating_sub(total_pages);
116 let mut component_options = options.clone();
117 component_options.max_pages = remaining_pages;
118 let mut page_sink = IcalPageSink {
119 inner: sink,
120 source_format,
121 page_offset: total_pages,
122 page_count: 0,
123 warnings: &component_warnings,
124 };
125 render_blocks_to_pages(&blocks, &mut page_sink, &component_options)?;
126 if page_sink.page_count == 0 {
127 return Err(Error::InvalidInput(
128 "calendar component produced no output pages".into(),
129 ));
130 }
131 total_pages = total_pages
132 .checked_add(page_sink.page_count)
133 .ok_or_else(|| Error::LimitExceeded("calendar page count overflowed".into()))?;
134 for warning in component_warnings {
135 if !warnings.contains(&warning) {
136 warnings.push(warning);
137 }
138 }
139 }
140 Ok(warnings)
141}
142
143fn parse_calendar(
144 bytes: &[u8],
145 max_pages: usize,
146 required_version: &str,
147) -> Result<(Option<String>, Vec<IcalComponent>, Vec<String>)> {
148 let lines = unfold_lines(bytes)?;
149 let mut stack = Vec::<String>::new();
150 let mut active_component: Option<IcalComponent> = None;
151 let mut components = Vec::new();
152 let mut calendar_name = None;
153 let mut calendar_version = None;
154 let mut warnings = Vec::new();
155 let mut has_timezone_component = false;
156 let mut has_unknown_component = false;
157 let mut calendar_started = false;
158 let mut calendar_ended = false;
159 let mut total_text_bytes = 0usize;
160 let mut total_properties = 0usize;
161
162 for (line_number, line) in lines.iter().enumerate() {
163 let line = std::str::from_utf8(line).map_err(|error| {
164 Error::InvalidInput(format!(
165 "calendar content line {} is not valid UTF-8: {error}",
166 line_number + 1
167 ))
168 })?;
169 let Some(property) = parse_property(line, line_number + 1)? else {
170 continue;
171 };
172 total_properties = total_properties.saturating_add(1);
173 if total_properties > MAX_ICAL_PROPERTIES_TOTAL {
174 return Err(Error::LimitExceeded(format!(
175 "calendar file exceeds {MAX_ICAL_PROPERTIES_TOTAL} content properties"
176 )));
177 }
178 total_text_bytes = total_text_bytes
179 .checked_add(property.value.len())
180 .ok_or_else(|| Error::LimitExceeded("calendar text size overflowed".into()))?;
181 if total_text_bytes > MAX_ICAL_TEXT_BYTES {
182 return Err(Error::LimitExceeded(format!(
183 "calendar values exceed {MAX_ICAL_TEXT_BYTES} bytes"
184 )));
185 }
186
187 if property.name == "BEGIN" {
188 let component_name = property.value.trim().to_ascii_uppercase();
189 if stack.len() >= MAX_ICAL_COMPONENT_DEPTH {
190 return Err(Error::LimitExceeded(format!(
191 "calendar component nesting exceeds {MAX_ICAL_COMPONENT_DEPTH}"
192 )));
193 }
194 if component_name == "VCALENDAR" {
195 if calendar_started || !stack.is_empty() {
196 return Err(Error::InvalidInput(
197 "calendar has a nested or repeated VCALENDAR".into(),
198 ));
199 }
200 calendar_started = true;
201 } else if matches!(
202 component_name.as_str(),
203 "VEVENT" | "VTODO" | "VJOURNAL" | "VFREEBUSY"
204 ) && stack.last().is_some_and(|parent| parent == "VCALENDAR")
205 {
206 if active_component.is_some() {
207 return Err(Error::InvalidInput(
208 "calendar components are unexpectedly nested".into(),
209 ));
210 }
211 let component_limit = max_pages.min(MAX_ICAL_COMPONENTS);
212 if components.len() >= component_limit {
213 return Err(Error::LimitExceeded(format!(
214 "calendar file exceeds {component_limit} output components"
215 )));
216 }
217 active_component = Some(IcalComponent {
218 kind: component_name.clone(),
219 properties: Vec::new(),
220 has_alarm: false,
221 has_recurrence: false,
222 has_attachment: false,
223 has_unknown_properties: false,
224 });
225 } else if component_name == "VTIMEZONE" {
226 has_timezone_component = true;
227 } else if component_name == "VALARM" && active_component.is_some() {
228 active_component
229 .as_mut()
230 .expect("active component exists")
231 .has_alarm = true;
232 } else if stack.last().is_some_and(|parent| parent == "VCALENDAR") {
233 has_unknown_component = true;
234 }
235 stack.push(component_name);
236 continue;
237 }
238
239 if property.name == "END" {
240 let component_name = property.value.trim().to_ascii_uppercase();
241 let Some(open_component) = stack.pop() else {
242 return Err(Error::InvalidInput(format!(
243 "calendar END:{component_name} has no matching BEGIN"
244 )));
245 };
246 if open_component != component_name {
247 return Err(Error::InvalidInput(format!(
248 "calendar component {open_component} ended as {component_name}"
249 )));
250 }
251 if component_name == "VCALENDAR" {
252 calendar_ended = true;
253 } else if matches!(
254 component_name.as_str(),
255 "VEVENT" | "VTODO" | "VJOURNAL" | "VFREEBUSY"
256 ) {
257 let Some(component) = active_component.take() else {
258 return Err(Error::InvalidInput(format!(
259 "calendar END:{component_name} has no parsed calendar component"
260 )));
261 };
262 if component.kind != component_name {
263 return Err(Error::InvalidInput(format!(
264 "calendar component {} ended as {component_name}",
265 component.kind
266 )));
267 }
268 let component_limit = max_pages.min(MAX_ICAL_COMPONENTS);
269 if components.len() >= component_limit {
270 return Err(Error::LimitExceeded(format!(
271 "calendar file exceeds {component_limit} output components"
272 )));
273 }
274 components.push(component);
275 }
276 continue;
277 }
278
279 if active_component.is_none() && stack.last().is_some_and(|item| item == "VCALENDAR") {
280 match property.name.as_str() {
281 "VERSION" => calendar_version = Some(property.value.clone()),
282 "X-WR-CALNAME" => calendar_name = Some(unescape_text(&property.value)),
283 _ => {}
284 }
285 continue;
286 }
287 let Some(component) = active_component.as_mut() else {
288 continue;
289 };
290 if stack.last().is_some_and(|item| item == "VALARM") {
291 continue;
292 }
293 if component.properties.len() >= MAX_ICAL_PROPERTIES_PER_COMPONENT {
294 return Err(Error::LimitExceeded(format!(
295 "calendar component exceeds {MAX_ICAL_PROPERTIES_PER_COMPONENT} properties"
296 )));
297 }
298 match property.name.as_str() {
299 "RRULE" | "RDATE" | "EXDATE" | "RECURRENCE-ID" => {
300 component.has_recurrence = true;
301 component.properties.push(property);
302 }
303 "ATTACH" => component.has_attachment = true,
304 "UID" | "DTSTAMP" | "DTSTART" | "DTEND" | "DUE" | "DURATION" | "SUMMARY"
305 | "DESCRIPTION" | "LOCATION" | "ORGANIZER" | "ATTENDEE" | "STATUS" | "PRIORITY"
306 | "URL" | "CATEGORIES" | "CLASS" | "TRANSP" | "FREEBUSY" | "CREATED"
307 | "LAST-MODIFIED" | "COMPLETED" | "PERCENT-COMPLETE" | "COMMENT" | "CONTACT"
308 | "GEO" | "RESOURCES" | "SEQUENCE" => {
309 component.properties.push(property);
310 }
311 _ => component.has_unknown_properties = true,
312 }
313 }
314 if !calendar_started || !calendar_ended || !stack.is_empty() || active_component.is_some() {
315 return Err(Error::InvalidInput(
316 "calendar file is missing a balanced VCALENDAR component".into(),
317 ));
318 }
319 if calendar_version.as_deref() != Some(required_version) {
320 return Err(Error::Unsupported(format!(
321 "calendar version {:?} is unsupported; version {required_version} is required",
322 calendar_version,
323 )));
324 }
325 if has_timezone_component {
326 warnings.push(
327 "calendar VTIMEZONE rules are retained as source values; timezone conversion is not performed".into(),
328 );
329 }
330 if has_unknown_component {
331 warnings.push("one or more unsupported calendar components were omitted".into());
332 }
333 Ok((calendar_name, components, warnings))
334}
335
336fn unfold_lines(bytes: &[u8]) -> Result<Vec<Vec<u8>>> {
337 let mut physical_count = 0usize;
338 let mut logical_lines = Vec::new();
339 let mut current = Vec::new();
340 for raw_line in bytes.split(|byte| *byte == b'\n') {
341 physical_count += 1;
342 if physical_count > MAX_ICAL_PHYSICAL_LINES {
343 return Err(Error::LimitExceeded(format!(
344 "calendar input exceeds {MAX_ICAL_PHYSICAL_LINES} physical lines"
345 )));
346 }
347 let line = raw_line.strip_suffix(b"\r").unwrap_or(raw_line);
348 if line.len() > MAX_ICAL_LINE_BYTES {
349 return Err(Error::LimitExceeded(format!(
350 "calendar physical line exceeds {MAX_ICAL_LINE_BYTES} bytes"
351 )));
352 }
353 if line
354 .first()
355 .is_some_and(|byte| matches!(byte, b' ' | b'\t'))
356 {
357 if current.is_empty() {
358 return Err(Error::InvalidInput(
359 "calendar folded content line has no previous line".into(),
360 ));
361 }
362 if current.len().saturating_add(line.len().saturating_sub(1)) > MAX_ICAL_LINE_BYTES {
363 return Err(Error::LimitExceeded(format!(
364 "unfolded calendar content line exceeds {MAX_ICAL_LINE_BYTES} bytes"
365 )));
366 }
367 current.extend_from_slice(&line[1..]);
368 } else {
369 if !current.is_empty() {
370 logical_lines.push(std::mem::take(&mut current));
371 if logical_lines.len() > MAX_ICAL_LOGICAL_LINES {
372 return Err(Error::LimitExceeded(format!(
373 "calendar input exceeds {MAX_ICAL_LOGICAL_LINES} logical lines"
374 )));
375 }
376 }
377 current.extend_from_slice(line);
378 }
379 }
380 if !current.is_empty() {
381 logical_lines.push(current);
382 }
383 if logical_lines.len() > MAX_ICAL_LOGICAL_LINES {
384 return Err(Error::LimitExceeded(format!(
385 "calendar input exceeds {MAX_ICAL_LOGICAL_LINES} logical lines"
386 )));
387 }
388 if logical_lines
389 .first()
390 .is_some_and(|line| line.starts_with(&[0xef, 0xbb, 0xbf]))
391 {
392 logical_lines[0].drain(..3);
393 }
394 Ok(logical_lines)
395}
396
397fn parse_property(line: &str, line_number: usize) -> Result<Option<IcalProperty>> {
398 let Some(colon) = find_unquoted_colon(line) else {
399 if line.is_empty() {
400 return Ok(None);
401 }
402 return Err(Error::InvalidInput(format!(
403 "calendar content line {line_number} has no property separator"
404 )));
405 };
406 let head = &line[..colon];
407 let value = &line[colon + 1..];
408 let mut parts = split_unquoted(head, ';').into_iter();
409 let name = parts.next().unwrap_or_default().trim().to_ascii_uppercase();
410 if name.is_empty()
411 || !name
412 .bytes()
413 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
414 {
415 return Err(Error::InvalidInput(format!(
416 "calendar content line {line_number} has an invalid property name"
417 )));
418 }
419 if parts.len() > MAX_ICAL_PARAMS_PER_PROPERTY {
420 return Err(Error::LimitExceeded(format!(
421 "calendar content line {line_number} exceeds {MAX_ICAL_PARAMS_PER_PROPERTY} parameters"
422 )));
423 }
424 let mut tzid = None;
425 for param in parts {
426 if let Some((key, value)) = param.split_once('=')
427 && key.trim().eq_ignore_ascii_case("TZID")
428 {
429 tzid = Some(unquote(value.trim()));
430 }
431 }
432 Ok(Some(IcalProperty {
433 name,
434 tzid,
435 value: value.to_owned(),
436 }))
437}
438
439fn find_unquoted_colon(line: &str) -> Option<usize> {
440 let mut quoted = false;
441 let mut escaped = false;
442 for (index, character) in line.char_indices() {
443 if escaped {
444 escaped = false;
445 continue;
446 }
447 if character == '\\' && quoted {
448 escaped = true;
449 } else if character == '"' {
450 quoted = !quoted;
451 } else if character == ':' && !quoted {
452 return Some(index);
453 }
454 }
455 None
456}
457
458fn split_unquoted(text: &str, delimiter: char) -> Vec<&str> {
459 let mut quoted = false;
460 let mut escaped = false;
461 let mut starts = Vec::new();
462 starts.push(0);
463 for (index, character) in text.char_indices() {
464 if escaped {
465 escaped = false;
466 continue;
467 }
468 if character == '\\' && quoted {
469 escaped = true;
470 } else if character == '"' {
471 quoted = !quoted;
472 } else if character == delimiter && !quoted {
473 starts.push(index + character.len_utf8());
474 }
475 }
476 let mut parts = Vec::with_capacity(starts.len());
477 for (index, start) in starts.iter().enumerate() {
478 let end = starts
479 .get(index + 1)
480 .map(|next| next - delimiter.len_utf8())
481 .unwrap_or(text.len());
482 parts.push(&text[*start..end]);
483 }
484 parts
485}
486
487fn unquote(value: &str) -> String {
488 if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') {
489 value[1..value.len() - 1].replace("\\\"", "\"")
490 } else {
491 value.to_owned()
492 }
493}
494
495fn unescape_text(value: &str) -> String {
496 let mut output = String::with_capacity(value.len());
497 let mut chars = value.chars();
498 while let Some(character) = chars.next() {
499 if character == '\\' {
500 match chars.next() {
501 Some('n' | 'N') => output.push('\n'),
502 Some('\\') => output.push('\\'),
503 Some(',') => output.push(','),
504 Some(';') => output.push(';'),
505 Some(other) => {
506 output.push('\\');
507 output.push(other);
508 }
509 None => output.push('\\'),
510 }
511 } else {
512 output.push(character);
513 }
514 }
515 output
516}
517
518fn render_component(
519 component: &IcalComponent,
520 calendar_name: Option<&str>,
521) -> (Vec<HtmlBlock>, Vec<String>) {
522 let mut warnings = Vec::new();
523 let summary = find_text_property(component, "SUMMARY")
524 .unwrap_or_else(|| format!("{} component", component.kind));
525 let mut blocks = vec![HtmlBlock::Heading {
526 level: 1,
527 text: summary,
528 }];
529 if let Some(name) = calendar_name.filter(|name| !name.trim().is_empty()) {
530 blocks.push(HtmlBlock::Paragraph {
531 text: format!("Calendar: {name}"),
532 });
533 }
534 for property in &component.properties {
535 let (label, value) = match property.name.as_str() {
536 "DTSTART" => ("Start", format_date_property(property)),
537 "DTEND" => ("End", format_date_property(property)),
538 "DUE" => ("Due", format_date_property(property)),
539 "DTSTAMP" => ("Created", format_date_property(property)),
540 "CREATED" => ("Created", format_date_property(property)),
541 "LAST-MODIFIED" => ("Updated", format_date_property(property)),
542 "COMPLETED" => ("Completed", format_date_property(property)),
543 "PERCENT-COMPLETE" => ("Complete", format!("{}%", property.value)),
544 "DURATION" => ("Duration", property.value.clone()),
545 "SUMMARY" => continue,
546 "DESCRIPTION" => {
547 let decoded = unescape_text(&property.value);
548 for paragraph in decoded
549 .split('\n')
550 .map(str::trim)
551 .filter(|line| !line.is_empty())
552 {
553 blocks.push(HtmlBlock::Paragraph {
554 text: paragraph.to_owned(),
555 });
556 }
557 continue;
558 }
559 "LOCATION" => ("Location", unescape_text(&property.value)),
560 "ORGANIZER" => ("Organizer", property.value.clone()),
561 "ATTENDEE" => ("Attendee", property.value.clone()),
562 "STATUS" => ("Status", property.value.clone()),
563 "PRIORITY" => ("Priority", property.value.clone()),
564 "URL" => ("URL", property.value.clone()),
565 "CATEGORIES" => ("Categories", unescape_text(&property.value)),
566 "FREEBUSY" => ("Free/busy", property.value.clone()),
567 "COMMENT" => ("Comment", unescape_text(&property.value)),
568 "CONTACT" => ("Contact", unescape_text(&property.value)),
569 "GEO" => ("Coordinates", property.value.clone()),
570 "RESOURCES" => ("Resources", unescape_text(&property.value)),
571 "CLASS" => ("Access class", property.value.clone()),
572 "TRANSP" => ("Transparency", property.value.clone()),
573 "ATTACH" | "RRULE" | "RDATE" | "EXDATE" | "RECURRENCE-ID" | "UID" | "SEQUENCE" => {
574 continue;
575 }
576 _ => continue,
577 };
578 let value = clean_text_value(&value);
579 if !value.trim().is_empty() {
580 blocks.push(HtmlBlock::Paragraph {
581 text: format!("{label}: {value}"),
582 });
583 }
584 if property.tzid.is_some() {
585 warnings.push(
586 "TZID values are shown without applying VTIMEZONE transitions or converting time zones".into(),
587 );
588 }
589 }
590 if component.has_recurrence {
591 warnings.push(format!(
592 "{} recurrence rules are displayed only as source properties and are not expanded",
593 component.kind
594 ));
595 for property in &component.properties {
596 if matches!(
597 property.name.as_str(),
598 "RRULE" | "RDATE" | "EXDATE" | "RECURRENCE-ID"
599 ) {
600 blocks.push(HtmlBlock::Paragraph {
601 text: format!("{}: {}", property.name, property.value),
602 });
603 }
604 }
605 }
606 if component.has_alarm {
607 warnings.push("VALARM triggers and alarm actions are not executed or rendered".into());
608 }
609 if component.has_attachment {
610 warnings.push("calendar ATTACH properties are not fetched or rendered".into());
611 }
612 if component.has_unknown_properties {
613 warnings.push("one or more unsupported calendar properties were omitted".into());
614 }
615 (blocks, warnings)
616}
617
618fn find_text_property(component: &IcalComponent, name: &str) -> Option<String> {
619 component
620 .properties
621 .iter()
622 .find(|property| property.name == name)
623 .map(|property| clean_text_value(&unescape_text(&property.value)))
624}
625
626fn format_date_property(property: &IcalProperty) -> String {
627 let raw = property.value.as_str();
628 let bytes = raw.as_bytes();
629 if bytes.len() == 8 && bytes.iter().all(u8::is_ascii_digit) {
630 format!("{}-{}-{} (all-day)", &raw[..4], &raw[4..6], &raw[6..8])
631 } else if bytes.len() >= 15
632 && bytes.get(8) == Some(&b'T')
633 && bytes[..8].iter().all(u8::is_ascii_digit)
634 && bytes[9..15].iter().all(u8::is_ascii_digit)
635 && (bytes.len() == 15 || (bytes.len() == 16 && bytes[15] == b'Z'))
636 {
637 let zone = if bytes.len() == 16 {
638 " UTC".to_owned()
639 } else {
640 property
641 .tzid
642 .as_ref()
643 .map(|tzid| format!(" (TZID={tzid})"))
644 .unwrap_or_else(|| " (floating local time)".into())
645 };
646 format!(
647 "{}-{}-{} {}:{}:{}{}",
648 &raw[..4],
649 &raw[4..6],
650 &raw[6..8],
651 &raw[9..11],
652 &raw[11..13],
653 &raw[13..15],
654 zone
655 )
656 } else {
657 raw.to_owned()
658 }
659}
660
661fn clean_text_value(value: &str) -> String {
662 value
663 .chars()
664 .map(|character| if character == '\r' { '\n' } else { character })
665 .filter(|character| matches!(character, '\n' | '\t') || !character.is_control())
666 .collect()
667}
668
669#[cfg(test)]
670mod tests {
671 use super::{parse_calendar, parse_property, render_component};
672 use crate::document::html::HtmlBlock;
673
674 #[test]
675 fn unfolds_a_physical_line_split_inside_a_utf8_character() {
676 let bytes = b"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:1\r\nSUMMARY:Caf\xc3\r\n \xa9 plan\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
677 let (_, components, _) = parse_calendar(bytes, 100, "2.0").unwrap();
678 let (blocks, _) = render_component(&components[0], None);
679 assert!(matches!(
680 &blocks[0],
681 HtmlBlock::Heading { text, .. } if text == "Café plan"
682 ));
683 }
684
685 #[test]
686 fn parses_quoted_parameters_and_escaped_calendar_text() {
687 let property = parse_property(
688 r#"ATTENDEE;CN="Doe, Jane";ROLE=REQ-PARTICIPANT:mailto:jane@example.test"#,
689 1,
690 )
691 .unwrap()
692 .unwrap();
693 assert_eq!(property.tzid, None);
694 assert_eq!(property.value, "mailto:jane@example.test");
695
696 let bytes = b"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:1\r\nSUMMARY:Planning\\, review\\; final\\nSecond line\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
697 let (_, components, _) = parse_calendar(bytes, 100, "2.0").unwrap();
698 let (blocks, _) = render_component(&components[0], None);
699 assert!(matches!(
700 &blocks[0],
701 HtmlBlock::Heading { text, .. } if text == "Planning, review; final\nSecond line"
702 ));
703 }
704}