1use 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}