Skip to main content

document_svg/document/
ical.rs

1//! Bounded iCalendar (`.ics`) event, task, journal, and free/busy preview.
2//!
3//! RFC 5545 content lines are unfolded before UTF-8 parsing. Calendar time
4//! values are displayed as supplied; recurrence rules, alarms, and time-zone
5//! rules are not executed or expanded.
6
7use 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}