Skip to main content

document_svg/document/
msproject.rs

1//! Bounded Microsoft Project XML interchange preview.
2//!
3//! Project XML is read as task data only. The preview shows supplied task
4//! dates, progress, summary bars, milestones, and simple dependency arrows;
5//! it does not recalculate schedules or load external references.
6
7use std::collections::HashMap;
8use std::io::Cursor;
9use std::path::Path;
10
11use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc};
12use quick_xml::Reader;
13use quick_xml::events::{BytesStart, Event};
14
15use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
16use crate::error::{Error, Result};
17use crate::ir::{IDENTITY, Node, Page, Paint, SourceMeta, Stroke, TextAnchor, TextRun};
18
19const MAX_PROJECT_XML_BYTES: u64 = 64 * 1024 * 1024;
20const MAX_PROJECT_XML_EVENTS: usize = 1_000_000;
21const MAX_PROJECT_XML_DEPTH: usize = 64;
22const MAX_PROJECT_TASKS: usize = 100_000;
23const MAX_PROJECT_DEPENDENCIES: usize = 500_000;
24const MAX_PROJECT_TEXT_BYTES: usize = 32 * 1024 * 1024;
25const MAX_PROJECT_FIELD_BYTES: usize = 1024 * 1024;
26const TASKS_PER_PAGE: usize = 25;
27const MAX_DEPENDENCY_EDGES_PER_PAGE: usize = 1_000;
28const MILLIS_PER_DAY: i64 = 86_400_000;
29const PROJECT_XML_NAMESPACE: &str = "http://schemas.microsoft.com/project";
30const PROJECT_XML_NAMESPACE_HTTPS: &str = "https://schemas.microsoft.com/project";
31const PAGE_WIDTH: f64 = 792.0;
32const PAGE_HEIGHT: f64 = 612.0;
33const AXIS_X: f64 = 296.0;
34const AXIS_WIDTH: f64 = 464.0;
35const TASKS_TOP: f64 = 111.0;
36const TASK_ROW_HEIGHT: f64 = 17.0;
37
38#[derive(Clone, Debug, Default)]
39struct ProjectTask {
40    uid: Option<u32>,
41    name: String,
42    start: Option<i64>,
43    finish: Option<i64>,
44    percent_complete: u8,
45    outline_level: u8,
46    summary: bool,
47    milestone: bool,
48    predecessors: Vec<u32>,
49}
50
51#[derive(Default)]
52struct TaskBuilder {
53    uid: Option<u32>,
54    name: String,
55    start: Option<i64>,
56    finish: Option<i64>,
57    percent_complete: u8,
58    outline_level: u8,
59    summary: bool,
60    milestone: bool,
61    predecessors: Vec<u32>,
62}
63
64#[derive(Clone, Copy)]
65enum CaptureField {
66    ProjectName,
67    Uid,
68    Name,
69    Start,
70    Finish,
71    PercentComplete,
72    OutlineLevel,
73    Summary,
74    Milestone,
75    PredecessorUid,
76}
77
78struct Capture {
79    field: CaptureField,
80    depth: usize,
81    text: String,
82}
83
84struct ParsedProject {
85    name: String,
86    tasks: Vec<ProjectTask>,
87    warnings: Vec<String>,
88}
89
90pub(crate) fn looks_like_project_xml_prefix(bytes: &[u8]) -> bool {
91    let mut reader = Reader::from_reader(Cursor::new(bytes));
92    reader.config_mut().trim_text(true);
93    let mut buffer = Vec::new();
94    loop {
95        match reader.read_event_into(&mut buffer) {
96            Ok(Event::Start(start) | Event::Empty(start)) => {
97                return local_name(start.name().as_ref()) == b"Project"
98                    && has_project_namespace(&start);
99            }
100            Ok(Event::Eof) | Err(_) => return false,
101            Ok(_) => buffer.clear(),
102        }
103        buffer.clear();
104    }
105}
106
107pub(crate) fn convert(
108    path: &Path,
109    options: &ConvertOptions,
110    sink: &mut dyn PageConsumer,
111) -> Result<Vec<String>> {
112    let bytes = read_limited_file(
113        path,
114        options.max_input_bytes.min(MAX_PROJECT_XML_BYTES),
115        "Microsoft Project XML input",
116    )?;
117    let project = parse_project(&bytes, options.max_xml_events.min(MAX_PROJECT_XML_EVENTS))?;
118    render_project(project, options.max_pages, sink)
119}
120
121fn parse_project(bytes: &[u8], max_events: usize) -> Result<ParsedProject> {
122    let mut reader = Reader::from_reader(Cursor::new(bytes));
123    reader.config_mut().trim_text(false);
124    let mut buffer = Vec::new();
125    let mut stack = Vec::<String>::new();
126    let mut capture = None::<Capture>;
127    let mut active_task = None::<TaskBuilder>;
128    let mut project_name = None::<String>;
129    let mut tasks = Vec::new();
130    let mut event_count = 0usize;
131    let mut total_text_bytes = 0usize;
132    let mut dependency_count = 0usize;
133    let mut invalid_date_values = 0usize;
134    let mut invalid_numeric_values = 0usize;
135    let mut saw_root = false;
136
137    loop {
138        event_count = event_count.saturating_add(1);
139        if event_count > max_events {
140            return Err(Error::LimitExceeded(format!(
141                "Microsoft Project XML exceeds {max_events} parser events"
142            )));
143        }
144        let event = reader
145            .read_event_into(&mut buffer)
146            .map_err(|error| Error::InvalidInput(format!("invalid Project XML: {error}")))?;
147        match event {
148            Event::Start(start) => {
149                let name = String::from_utf8_lossy(local_name(start.name().as_ref())).into_owned();
150                let parent = stack.last().map(String::as_str).unwrap_or_default();
151                let depth = stack.len().saturating_add(1);
152                if depth > MAX_PROJECT_XML_DEPTH {
153                    return Err(Error::LimitExceeded(format!(
154                        "Microsoft Project XML exceeds {MAX_PROJECT_XML_DEPTH} levels of nesting"
155                    )));
156                }
157                if !saw_root {
158                    if name != "Project" || !has_project_namespace(&start) {
159                        return Err(Error::Unsupported(
160                            "XML is not a Microsoft Project XML interchange document".into(),
161                        ));
162                    }
163                    saw_root = true;
164                }
165                if name == "Task" && parent == "Tasks" {
166                    if active_task.is_some() {
167                        return Err(Error::InvalidInput(
168                            "Microsoft Project XML contains nested Task elements".into(),
169                        ));
170                    }
171                    if tasks.len() >= MAX_PROJECT_TASKS {
172                        return Err(Error::LimitExceeded(format!(
173                            "Microsoft Project XML exceeds {MAX_PROJECT_TASKS} tasks"
174                        )));
175                    }
176                    active_task = Some(TaskBuilder::default());
177                }
178                let field = if active_task.is_some() {
179                    if parent == "Task" {
180                        match name.as_str() {
181                            "UID" => Some(CaptureField::Uid),
182                            "Name" => Some(CaptureField::Name),
183                            "Start" => Some(CaptureField::Start),
184                            "Finish" => Some(CaptureField::Finish),
185                            "PercentComplete" => Some(CaptureField::PercentComplete),
186                            "OutlineLevel" => Some(CaptureField::OutlineLevel),
187                            "Summary" => Some(CaptureField::Summary),
188                            "Milestone" => Some(CaptureField::Milestone),
189                            _ => None,
190                        }
191                    } else if parent == "PredecessorLink" && name == "PredecessorUID" {
192                        Some(CaptureField::PredecessorUid)
193                    } else {
194                        None
195                    }
196                } else if parent == "Project" && name == "Name" && project_name.is_none() {
197                    Some(CaptureField::ProjectName)
198                } else {
199                    None
200                };
201                if let Some(field) = field {
202                    capture = Some(Capture {
203                        field,
204                        depth,
205                        text: String::new(),
206                    });
207                }
208                stack.push(name);
209            }
210            Event::Empty(start) => {
211                let name = String::from_utf8_lossy(local_name(start.name().as_ref())).into_owned();
212                let parent = stack.last().map(String::as_str).unwrap_or_default();
213                if !saw_root {
214                    if name != "Project" || !has_project_namespace(&start) {
215                        return Err(Error::Unsupported(
216                            "XML is not a Microsoft Project XML interchange document".into(),
217                        ));
218                    }
219                    saw_root = true;
220                }
221                if name == "Task" && parent == "Tasks" {
222                    return Err(Error::InvalidInput(
223                        "Microsoft Project XML contains an empty Task element".into(),
224                    ));
225                }
226            }
227            Event::Text(text) => {
228                let decoded = text.decode().map_err(|error| {
229                    Error::InvalidInput(format!("invalid Project XML text: {error}"))
230                })?;
231                let unescaped = quick_xml::escape::unescape(&decoded).map_err(|error| {
232                    Error::InvalidInput(format!("invalid Project XML entity: {error}"))
233                })?;
234                append_capture(
235                    capture.as_mut(),
236                    stack.len(),
237                    &unescaped,
238                    &mut total_text_bytes,
239                )?;
240            }
241            Event::CData(text) => {
242                let decoded = text.decode().map_err(|error| {
243                    Error::InvalidInput(format!("invalid Project XML CDATA: {error}"))
244                })?;
245                append_capture(
246                    capture.as_mut(),
247                    stack.len(),
248                    &decoded,
249                    &mut total_text_bytes,
250                )?;
251            }
252            Event::GeneralRef(reference) => {
253                let decoded = crate::ooxml::decode_xml_reference(&reference, "Project XML")?;
254                append_capture(
255                    capture.as_mut(),
256                    stack.len(),
257                    &decoded,
258                    &mut total_text_bytes,
259                )?;
260            }
261            Event::End(end) => {
262                let name = String::from_utf8_lossy(local_name(end.name().as_ref())).into_owned();
263                if stack.last().map(String::as_str) != Some(name.as_str()) {
264                    return Err(Error::InvalidInput(
265                        "Microsoft Project XML has mismatched element tags".into(),
266                    ));
267                }
268                if capture
269                    .as_ref()
270                    .is_some_and(|capture| capture.depth == stack.len())
271                {
272                    let captured = capture.take().expect("capture matched its depth");
273                    apply_field(
274                        captured.field,
275                        captured.text,
276                        &mut active_task,
277                        &mut project_name,
278                        &mut invalid_date_values,
279                        &mut invalid_numeric_values,
280                        &mut dependency_count,
281                    )?;
282                }
283                if name == "Task" && stack.len() == 3 && stack.get(1).is_some_and(|v| v == "Tasks")
284                {
285                    let builder = active_task.take().ok_or_else(|| {
286                        Error::InvalidInput("Microsoft Project Task state was lost".into())
287                    })?;
288                    tasks.push(finish_task(builder, tasks.len() + 1));
289                }
290                stack.pop();
291            }
292            Event::DocType(_) => {
293                return Err(Error::InvalidInput(
294                    "Microsoft Project XML document type declarations are not supported".into(),
295                ));
296            }
297            Event::Eof => break,
298            _ => {}
299        }
300        buffer.clear();
301    }
302    if !saw_root || !stack.is_empty() || active_task.is_some() || capture.is_some() {
303        return Err(Error::InvalidInput(
304            "Microsoft Project XML ended inside an incomplete document or Task".into(),
305        ));
306    }
307    if tasks.is_empty() {
308        return Err(Error::InvalidInput(
309            "Microsoft Project XML contains no Tasks/Task records".into(),
310        ));
311    }
312    let mut warnings = Vec::new();
313    if invalid_date_values > 0 {
314        warnings.push(format!(
315            "{invalid_date_values} invalid Microsoft Project date value(s) were omitted"
316        ));
317    }
318    if invalid_numeric_values > 0 {
319        warnings.push(format!(
320            "{invalid_numeric_values} invalid or out-of-range Microsoft Project progress/outline value(s) were clamped or defaulted"
321        ));
322    }
323    Ok(ParsedProject {
324        name: project_name
325            .map(|name| name.trim().to_owned())
326            .filter(|name| !name.is_empty())
327            .unwrap_or_else(|| "Microsoft Project schedule".into()),
328        tasks,
329        warnings,
330    })
331}
332
333fn append_capture(
334    capture: Option<&mut Capture>,
335    depth: usize,
336    text: &str,
337    total_text_bytes: &mut usize,
338) -> Result<()> {
339    let Some(capture) = capture.filter(|capture| capture.depth == depth) else {
340        return Ok(());
341    };
342    *total_text_bytes = total_text_bytes.saturating_add(text.len());
343    if *total_text_bytes > MAX_PROJECT_TEXT_BYTES {
344        return Err(Error::LimitExceeded(format!(
345            "Microsoft Project XML text exceeds {MAX_PROJECT_TEXT_BYTES} bytes"
346        )));
347    }
348    if capture.text.len().saturating_add(text.len()) > MAX_PROJECT_FIELD_BYTES {
349        return Err(Error::LimitExceeded(format!(
350            "Microsoft Project XML field exceeds {MAX_PROJECT_FIELD_BYTES} bytes"
351        )));
352    }
353    capture.text.push_str(text);
354    Ok(())
355}
356
357#[allow(clippy::too_many_arguments)]
358fn apply_field(
359    field: CaptureField,
360    text: String,
361    active_task: &mut Option<TaskBuilder>,
362    project_name: &mut Option<String>,
363    invalid_date_values: &mut usize,
364    invalid_numeric_values: &mut usize,
365    dependency_count: &mut usize,
366) -> Result<()> {
367    let value = text.trim();
368    match field {
369        CaptureField::ProjectName => *project_name = Some(value.to_owned()),
370        CaptureField::Uid => {
371            if let Some(task) = active_task.as_mut() {
372                task.uid = value.parse().ok();
373            }
374        }
375        CaptureField::Name => {
376            if let Some(task) = active_task.as_mut() {
377                task.name = value.to_owned();
378            }
379        }
380        CaptureField::Start => {
381            if let Some(task) = active_task.as_mut() {
382                task.start = parse_project_timestamp(value);
383                if !value.is_empty() && task.start.is_none() {
384                    *invalid_date_values = invalid_date_values.saturating_add(1);
385                }
386            }
387        }
388        CaptureField::Finish => {
389            if let Some(task) = active_task.as_mut() {
390                task.finish = parse_project_timestamp(value);
391                if !value.is_empty() && task.finish.is_none() {
392                    *invalid_date_values = invalid_date_values.saturating_add(1);
393                }
394            }
395        }
396        CaptureField::PercentComplete => {
397            if let Some(task) = active_task.as_mut()
398                && !value.is_empty()
399            {
400                match value.parse::<i64>() {
401                    Ok(value) => {
402                        task.percent_complete = value.clamp(0, 100) as u8;
403                        if !(0..=100).contains(&value) {
404                            *invalid_numeric_values = invalid_numeric_values.saturating_add(1);
405                        }
406                    }
407                    Err(_) => {
408                        *invalid_numeric_values = invalid_numeric_values.saturating_add(1);
409                    }
410                }
411            }
412        }
413        CaptureField::OutlineLevel => {
414            if let Some(task) = active_task.as_mut()
415                && !value.is_empty()
416            {
417                match value.parse::<u8>() {
418                    Ok(value) => task.outline_level = value.min(8),
419                    Err(_) => *invalid_numeric_values = invalid_numeric_values.saturating_add(1),
420                }
421            }
422        }
423        CaptureField::Summary => {
424            if let Some(task) = active_task.as_mut() {
425                task.summary = parse_project_bool(value);
426            }
427        }
428        CaptureField::Milestone => {
429            if let Some(task) = active_task.as_mut() {
430                task.milestone = parse_project_bool(value);
431            }
432        }
433        CaptureField::PredecessorUid => {
434            if !value.is_empty() {
435                let predecessor = value.parse::<u32>().map_err(|_| {
436                    Error::InvalidInput("Microsoft Project PredecessorUID is invalid".into())
437                })?;
438                *dependency_count = dependency_count.saturating_add(1);
439                if *dependency_count > MAX_PROJECT_DEPENDENCIES {
440                    return Err(Error::LimitExceeded(format!(
441                        "Microsoft Project XML exceeds {MAX_PROJECT_DEPENDENCIES} predecessor links"
442                    )));
443                }
444                if let Some(task) = active_task.as_mut() {
445                    task.predecessors.push(predecessor);
446                }
447            }
448        }
449    }
450    Ok(())
451}
452
453fn finish_task(builder: TaskBuilder, index: usize) -> ProjectTask {
454    ProjectTask {
455        uid: builder.uid,
456        name: if builder.name.trim().is_empty() {
457            format!("Task {index}")
458        } else {
459            builder.name.trim().to_owned()
460        },
461        start: builder.start,
462        finish: builder.finish,
463        percent_complete: builder.percent_complete,
464        outline_level: builder.outline_level,
465        summary: builder.summary,
466        milestone: builder.milestone,
467        predecessors: builder.predecessors,
468    }
469}
470
471fn parse_project_timestamp(value: &str) -> Option<i64> {
472    if value.is_empty() {
473        return None;
474    }
475    DateTime::parse_from_rfc3339(value)
476        .ok()
477        .map(|date| date.timestamp_millis())
478        .or_else(|| {
479            NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S%.f")
480                .ok()
481                .map(|date| date.and_utc().timestamp_millis())
482        })
483        .or_else(|| {
484            NaiveDate::parse_from_str(value, "%Y-%m-%d")
485                .ok()?
486                .and_hms_opt(0, 0, 0)
487                .map(|date| date.and_utc().timestamp_millis())
488        })
489}
490
491fn parse_project_bool(value: &str) -> bool {
492    matches!(value.trim(), "1" | "true" | "TRUE" | "True")
493}
494
495fn render_project(
496    mut project: ParsedProject,
497    max_pages: usize,
498    sink: &mut dyn PageConsumer,
499) -> Result<Vec<String>> {
500    for task in &mut project.tasks {
501        if let (Some(start), Some(finish)) = (task.start, task.finish)
502            && finish < start
503        {
504            task.start = None;
505            task.finish = None;
506            project.warnings.push(format!(
507                "task '{}' has Finish earlier than Start and its bar was omitted",
508                task.name
509            ));
510        }
511    }
512    let page_count = project.tasks.len().div_ceil(TASKS_PER_PAGE);
513    if page_count > max_pages {
514        return Err(Error::LimitExceeded(format!(
515            "Microsoft Project schedule needs {page_count} pages for {} tasks; maximum is {max_pages}",
516            project.tasks.len()
517        )));
518    }
519    let bounds = project
520        .tasks
521        .iter()
522        .flat_map(|task| [task.start, task.finish].into_iter().flatten())
523        .fold(None::<(i64, i64)>, |bounds, value| {
524            Some(match bounds {
525                Some((minimum, maximum)) => (minimum.min(value), maximum.max(value)),
526                None => (value, value),
527            })
528        });
529    let (minimum, mut maximum) = bounds.unwrap_or((0, MILLIS_PER_DAY));
530    if maximum <= minimum {
531        maximum = minimum.saturating_add(MILLIS_PER_DAY);
532    }
533    let range = maximum.saturating_sub(minimum).max(1) as f64;
534    let mut uid_to_index = HashMap::new();
535    let mut duplicate_uids = false;
536    for (index, task) in project.tasks.iter().enumerate() {
537        if let Some(uid) = task.uid
538            && uid_to_index.insert(uid, index).is_some()
539        {
540            duplicate_uids = true;
541        }
542    }
543    let mut warnings = project.warnings;
544    if bounds.is_none() {
545        push_warning_once(
546            &mut warnings,
547            "no valid Start/Finish dates were found; task names and progress are shown without schedule bars",
548        );
549    }
550    if project
551        .tasks
552        .iter()
553        .any(|task| task.start.is_none() || task.finish.is_none())
554    {
555        push_warning_once(
556            &mut warnings,
557            "tasks without both Start and Finish dates are shown without a schedule bar",
558        );
559    }
560    if project
561        .tasks
562        .iter()
563        .any(|task| !task.predecessors.is_empty())
564    {
565        push_warning_once(
566            &mut warnings,
567            "predecessor relationships are drawn as simple arrows; calendars, lag, and dependency types are not recalculated",
568        );
569    }
570    if duplicate_uids {
571        push_warning_once(
572            &mut warnings,
573            "duplicate Microsoft Project task UID values make some predecessor targets ambiguous",
574        );
575    }
576    let mut predecessor_edges_per_page = vec![0usize; page_count];
577    let mut missing_edges = false;
578    let mut cross_page_edges = false;
579    let mut truncated_edges = false;
580    for (task_index, task) in project.tasks.iter().enumerate() {
581        for predecessor in &task.predecessors {
582            let Some(&predecessor_index) = uid_to_index.get(predecessor) else {
583                missing_edges = true;
584                continue;
585            };
586            if predecessor_index / TASKS_PER_PAGE != task_index / TASKS_PER_PAGE {
587                cross_page_edges = true;
588                continue;
589            }
590            if project.tasks[predecessor_index].finish.is_none() || task.start.is_none() {
591                missing_edges = true;
592                continue;
593            }
594            let page_index = task_index / TASKS_PER_PAGE;
595            predecessor_edges_per_page[page_index] =
596                predecessor_edges_per_page[page_index].saturating_add(1);
597            if predecessor_edges_per_page[page_index] > MAX_DEPENDENCY_EDGES_PER_PAGE {
598                truncated_edges = true;
599            }
600        }
601    }
602    if truncated_edges {
603        push_warning_once(
604            &mut warnings,
605            "predecessor arrow count per page exceeded the preview limit; remaining arrows were omitted",
606        );
607    }
608    if missing_edges {
609        push_warning_once(
610            &mut warnings,
611            "predecessor links with missing task dates or task IDs were omitted",
612        );
613    }
614    if cross_page_edges {
615        push_warning_once(
616            &mut warnings,
617            "predecessor arrows between tasks on different pages are omitted",
618        );
619    }
620    warnings = deduplicate(warnings);
621
622    for (page_index, page_tasks) in project.tasks.chunks(TASKS_PER_PAGE).enumerate() {
623        let page_number = page_index + 1;
624        let first_task_index = page_index * TASKS_PER_PAGE;
625        let mut page = Page::new(page_number, PAGE_WIDTH, PAGE_HEIGHT, "msproject");
626        page.title = format!(
627            "{} — tasks {}–{}",
628            project.name,
629            first_task_index + 1,
630            first_task_index + page_tasks.len()
631        );
632        page.description = format!(
633            "Microsoft Project schedule page {page_number} with {} task rows",
634            page_tasks.len()
635        );
636        for warning in &warnings {
637            page.warn(warning.clone());
638        }
639        push_text(
640            &mut page,
641            "project-title",
642            32.0,
643            34.0,
644            &truncate(&project.name, 70),
645            18.0,
646            true,
647            "#172554",
648            "office:project-title",
649        );
650        let range_text = if bounds.is_some() {
651            format!(
652                "{} – {}  ·  {} tasks",
653                format_project_date(minimum),
654                format_project_date(maximum),
655                project.tasks.len()
656            )
657        } else {
658            format!("{} tasks  ·  no scheduled dates", project.tasks.len())
659        };
660        push_text(
661            &mut page,
662            "project-range",
663            32.0,
664            54.0,
665            &range_text,
666            9.0,
667            false,
668            "#475569",
669            "office:project-metadata",
670        );
671        push_text(
672            &mut page,
673            "task-column-heading",
674            32.0,
675            91.0,
676            "Task",
677            9.0,
678            true,
679            "#334155",
680            "office:project-column-heading",
681        );
682        push_text(
683            &mut page,
684            "progress-column-heading",
685            251.0,
686            91.0,
687            "%",
688            9.0,
689            true,
690            "#334155",
691            "office:project-column-heading",
692        );
693        let axis_bottom = TASKS_TOP + TASK_ROW_HEIGHT * TASKS_PER_PAGE as f64;
694        for tick in 0..=4 {
695            let fraction = f64::from(tick) / 4.0;
696            let x = AXIS_X + AXIS_WIDTH * fraction;
697            let date = minimum.saturating_add((range * fraction).round() as i64);
698            push_line(
699                &mut page,
700                format!("timeline-grid-{tick}"),
701                x,
702                TASKS_TOP - 6.0,
703                x,
704                axis_bottom,
705                "#CBD5E1",
706                0.55,
707            );
708            push_text(
709                &mut page,
710                format!("timeline-label-{tick}"),
711                (x - 25.0).max(AXIS_X),
712                91.0,
713                &format_project_date(date),
714                8.0,
715                false,
716                "#475569",
717                "office:project-date-label",
718            );
719        }
720        let page_uid_to_row = page_tasks
721            .iter()
722            .enumerate()
723            .filter_map(|(row, task)| task.uid.map(|uid| (uid, row)))
724            .collect::<HashMap<_, _>>();
725        let mut edge_count = 0usize;
726        for (row, task) in page_tasks.iter().enumerate() {
727            let target_start = task.start.map(|date| date_to_x(date, minimum, range));
728            for predecessor in &task.predecessors {
729                let Some(&predecessor_row) = page_uid_to_row.get(predecessor) else {
730                    continue;
731                };
732                let predecessor_task = &page_tasks[predecessor_row];
733                let (Some(predecessor_finish), Some(target_start)) =
734                    (predecessor_task.finish, target_start)
735                else {
736                    continue;
737                };
738                if edge_count >= MAX_DEPENDENCY_EDGES_PER_PAGE {
739                    break;
740                }
741                let start_x = date_to_x(predecessor_finish, minimum, range);
742                let end_x = target_start;
743                let start_y = TASKS_TOP + predecessor_row as f64 * TASK_ROW_HEIGHT + 8.0;
744                let end_y = TASKS_TOP + row as f64 * TASK_ROW_HEIGHT + 8.0;
745                push_dependency(
746                    &mut page,
747                    format!("dependency-{page_number}-{edge_count}"),
748                    start_x,
749                    start_y,
750                    end_x,
751                    end_y,
752                );
753                edge_count += 1;
754            }
755        }
756        for (row, task) in page_tasks.iter().enumerate() {
757            let y = TASKS_TOP + row as f64 * TASK_ROW_HEIGHT;
758            push_line(
759                &mut page,
760                format!("task-row-{page_number}-{row}"),
761                32.0,
762                y + TASK_ROW_HEIGHT,
763                760.0,
764                y + TASK_ROW_HEIGHT,
765                "#E2E8F0",
766                0.45,
767            );
768            let indent = f64::from(task.outline_level.min(8)) * 7.0;
769            push_text(
770                &mut page,
771                format!("task-label-{page_number}-{row}"),
772                32.0 + indent,
773                y + 12.0,
774                &truncate(&task.name, 30),
775                8.5,
776                task.summary,
777                if task.summary { "#1E293B" } else { "#334155" },
778                "office:project-task",
779            );
780            push_text(
781                &mut page,
782                format!("task-progress-{page_number}-{row}"),
783                251.0,
784                y + 12.0,
785                &format!("{}%", task.percent_complete),
786                8.0,
787                false,
788                "#475569",
789                "office:project-progress",
790            );
791            let start = task.start;
792            let finish = task.finish;
793            if task.milestone || (start.is_some() && start == finish) {
794                if let Some(date) = start.or(finish) {
795                    push_milestone(
796                        &mut page,
797                        format!("milestone-{page_number}-{row}"),
798                        date_to_x(date, minimum, range),
799                        y + 8.0,
800                        task.summary,
801                    );
802                }
803                continue;
804            }
805            if let (Some(start), Some(finish)) = (start, finish) {
806                let x1 = date_to_x(start, minimum, range);
807                let x2 = date_to_x(finish, minimum, range);
808                let width = (x2 - x1).max(2.0);
809                if task.summary {
810                    push_summary_bar(
811                        &mut page,
812                        format!("summary-bar-{page_number}-{row}"),
813                        x1,
814                        y + 8.0,
815                        width,
816                    );
817                } else {
818                    let bar_width = width.min(AXIS_X + AXIS_WIDTH - x1);
819                    push_bar(
820                        &mut page,
821                        format!("task-bar-{page_number}-{row}"),
822                        x1,
823                        y + 4.5,
824                        bar_width,
825                        7.0,
826                        "#60A5FA",
827                    );
828                    let progress_width = bar_width * f64::from(task.percent_complete) / 100.0;
829                    if progress_width > 0.0 {
830                        push_bar(
831                            &mut page,
832                            format!("task-progress-bar-{page_number}-{row}"),
833                            x1,
834                            y + 4.5,
835                            progress_width,
836                            7.0,
837                            "#2563EB",
838                        );
839                    }
840                }
841            }
842        }
843        if page_number < page_count {
844            push_text(
845                &mut page,
846                "page-number",
847                PAGE_WIDTH - 52.0,
848                PAGE_HEIGHT - 18.0,
849                &format!("{page_number} / {page_count}"),
850                8.0,
851                false,
852                "#64748B",
853                "office:page-number",
854            );
855        }
856        sink.consume(page)?;
857    }
858    Ok(warnings)
859}
860
861fn date_to_x(date: i64, minimum: i64, range: f64) -> f64 {
862    AXIS_X + ((date.saturating_sub(minimum) as f64 / range).clamp(0.0, 1.0) * AXIS_WIDTH)
863}
864
865fn format_project_date(timestamp_millis: i64) -> String {
866    DateTime::<Utc>::from_timestamp_millis(timestamp_millis)
867        .map(|date| date.format("%Y-%m-%d").to_string())
868        .unwrap_or_else(|| "invalid date".into())
869}
870
871fn truncate(value: &str, maximum_chars: usize) -> String {
872    let mut characters = value.chars();
873    let value = characters.by_ref().take(maximum_chars).collect::<String>();
874    if characters.next().is_some() {
875        format!("{value}…")
876    } else {
877        value
878    }
879}
880
881#[allow(clippy::too_many_arguments)]
882fn push_text(
883    page: &mut Page,
884    id: impl Into<String>,
885    x: f64,
886    y: f64,
887    text: &str,
888    font_size: f64,
889    bold: bool,
890    color: &str,
891    role: &str,
892) {
893    page.nodes.push(Node::Text {
894        id: id.into(),
895        x,
896        y,
897        runs: vec![TextRun {
898            text: text.to_owned(),
899            font_family: "Arial, sans-serif".into(),
900            font_size,
901            bold,
902            fill: Paint::solid(color),
903            ..TextRun::default()
904        }],
905        anchor: TextAnchor::Start,
906        transform: IDENTITY,
907        opacity: 1.0,
908        stroke: Stroke::default(),
909        clip_id: None,
910        meta: SourceMeta {
911            semantic_role: role.into(),
912            ..SourceMeta::default()
913        },
914    });
915}
916
917#[allow(clippy::too_many_arguments)]
918fn push_line(
919    page: &mut Page,
920    id: impl Into<String>,
921    x1: f64,
922    y1: f64,
923    x2: f64,
924    y2: f64,
925    color: &str,
926    width: f64,
927) {
928    page.nodes.push(Node::Path {
929        id: id.into(),
930        d: format!("M {x1} {y1} L {x2} {y2}"),
931        fill_rule: "nonzero".into(),
932        fill: Paint::None,
933        stroke: Stroke {
934            paint: Paint::solid(color),
935            width,
936            ..Stroke::default()
937        },
938        transform: IDENTITY,
939        clip_id: None,
940        meta: SourceMeta::default(),
941    });
942}
943
944fn push_bar(
945    page: &mut Page,
946    id: impl Into<String>,
947    x: f64,
948    y: f64,
949    width: f64,
950    height: f64,
951    color: &str,
952) {
953    if width <= 0.0 || !width.is_finite() {
954        return;
955    }
956    page.nodes.push(Node::Path {
957        id: id.into(),
958        d: format!("M {x} {y} H {} V {} H {x} Z", x + width, y + height),
959        fill_rule: "nonzero".into(),
960        fill: Paint::solid(color),
961        stroke: Stroke::default(),
962        transform: IDENTITY,
963        clip_id: None,
964        meta: SourceMeta {
965            semantic_role: "office:project-task-bar".into(),
966            ..SourceMeta::default()
967        },
968    });
969}
970
971fn push_summary_bar(page: &mut Page, id: impl Into<String>, x: f64, y: f64, width: f64) {
972    let end = x + width;
973    page.nodes.push(Node::Path {
974        id: id.into(),
975        d: format!(
976            "M {x} {y} V {} M {x} {y} H {end} M {end} {y} V {}",
977            y + 5.0,
978            y + 5.0
979        ),
980        fill_rule: "nonzero".into(),
981        fill: Paint::None,
982        stroke: Stroke {
983            paint: Paint::solid("#334155"),
984            width: 1.8,
985            ..Stroke::default()
986        },
987        transform: IDENTITY,
988        clip_id: None,
989        meta: SourceMeta {
990            semantic_role: "office:project-summary-bar".into(),
991            ..SourceMeta::default()
992        },
993    });
994}
995
996fn push_milestone(page: &mut Page, id: impl Into<String>, x: f64, y: f64, summary: bool) {
997    let radius = if summary { 4.5 } else { 4.0 };
998    page.nodes.push(Node::Path {
999        id: id.into(),
1000        d: format!(
1001            "M {x} {} L {} {y} L {x} {} L {} {y} Z",
1002            y - radius,
1003            x + radius,
1004            y + radius,
1005            x - radius
1006        ),
1007        fill_rule: "nonzero".into(),
1008        fill: Paint::solid("#DC2626"),
1009        stroke: Stroke::default(),
1010        transform: IDENTITY,
1011        clip_id: None,
1012        meta: SourceMeta {
1013            semantic_role: "office:project-milestone".into(),
1014            ..SourceMeta::default()
1015        },
1016    });
1017}
1018
1019fn push_dependency(page: &mut Page, id: String, x1: f64, y1: f64, x2: f64, y2: f64) {
1020    let direction = if x2 >= x1 { 1.0 } else { -1.0 };
1021    let elbow = (x1 + direction * 6.0).clamp(AXIS_X, AXIS_X + AXIS_WIDTH);
1022    page.nodes.push(Node::Path {
1023        id: id.clone(),
1024        d: format!("M {x1} {y1} H {elbow} V {y2} H {x2}"),
1025        fill_rule: "nonzero".into(),
1026        fill: Paint::None,
1027        stroke: Stroke {
1028            paint: Paint::solid("#64748B"),
1029            width: 0.75,
1030            ..Stroke::default()
1031        },
1032        transform: IDENTITY,
1033        clip_id: None,
1034        meta: SourceMeta {
1035            semantic_role: "office:project-dependency".into(),
1036            ..SourceMeta::default()
1037        },
1038    });
1039    let head = 3.0;
1040    page.nodes.push(Node::Path {
1041        id: format!("{id}-arrow"),
1042        d: format!(
1043            "M {x2} {y2} L {} {} L {} {} Z",
1044            x2 - direction * head,
1045            y2 - head,
1046            x2 - direction * head,
1047            y2 + head
1048        ),
1049        fill_rule: "nonzero".into(),
1050        fill: Paint::solid("#64748B"),
1051        stroke: Stroke::default(),
1052        transform: IDENTITY,
1053        clip_id: None,
1054        meta: SourceMeta {
1055            semantic_role: "office:project-dependency-arrow".into(),
1056            ..SourceMeta::default()
1057        },
1058    });
1059}
1060
1061fn push_warning_once(warnings: &mut Vec<String>, warning: &str) {
1062    if !warnings.iter().any(|existing| existing == warning) {
1063        warnings.push(warning.into());
1064    }
1065}
1066
1067fn deduplicate(warnings: Vec<String>) -> Vec<String> {
1068    let mut unique = Vec::new();
1069    for warning in warnings {
1070        push_warning_once(&mut unique, &warning);
1071    }
1072    unique
1073}
1074
1075fn has_project_namespace(start: &BytesStart<'_>) -> bool {
1076    start
1077        .attributes()
1078        .with_checks(false)
1079        .flatten()
1080        .any(|attribute| {
1081            let key = attribute.key.as_ref();
1082            if key != b"xmlns" && !key.starts_with(b"xmlns:") {
1083                return false;
1084            }
1085            attribute.value.as_ref() == PROJECT_XML_NAMESPACE.as_bytes()
1086                || attribute.value.as_ref() == PROJECT_XML_NAMESPACE_HTTPS.as_bytes()
1087        })
1088}
1089
1090fn local_name(name: &[u8]) -> &[u8] {
1091    name.rsplit(|byte| *byte == b':').next().unwrap_or(name)
1092}
1093
1094#[cfg(test)]
1095mod tests {
1096    use super::*;
1097
1098    #[test]
1099    fn detects_the_project_xml_root_and_namespace() {
1100        assert!(looks_like_project_xml_prefix(
1101            br#"<?xml version="1.0"?><Project xmlns="http://schemas.microsoft.com/project">"#
1102        ));
1103        assert!(looks_like_project_xml_prefix(
1104            br#"<p:Project xmlns:p="https://schemas.microsoft.com/project">"#
1105        ));
1106        assert!(!looks_like_project_xml_prefix(
1107            br#"<Project xmlns="urn:other">"#
1108        ));
1109    }
1110
1111    #[test]
1112    fn parses_project_timestamps_with_or_without_offsets() {
1113        let utc = parse_project_timestamp("2026-09-01T08:00:00Z").unwrap();
1114        let offset = parse_project_timestamp("2026-09-01T10:00:00+02:00").unwrap();
1115        let naive = parse_project_timestamp("2026-09-01T08:00:00").unwrap();
1116        assert_eq!(utc, offset);
1117        assert_eq!(utc, naive);
1118        assert!(parse_project_timestamp("2026-02-30T08:00:00").is_none());
1119    }
1120
1121    #[test]
1122    fn rejects_doctypes_and_external_entities() {
1123        let xml = br#"<!DOCTYPE Project [<!ENTITY external SYSTEM "file:///etc/passwd">]><Project xmlns="http://schemas.microsoft.com/project"><Name>&external;</Name><Tasks><Task><UID>1</UID><Name>Task</Name></Task></Tasks></Project>"#;
1124        assert!(matches!(
1125            parse_project(xml, MAX_PROJECT_XML_EVENTS),
1126            Err(Error::InvalidInput(message)) if message.contains("document type")
1127        ));
1128    }
1129}