From c27ba404c05527431d94199159974ca9d58f6c64 Mon Sep 17 00:00:00 2001
From: "Rodrigo Rodriguez (Pragmatismo)"
Date: Sun, 11 Jan 2026 12:22:14 -0300
Subject: [PATCH] feat(office): Add Phase 4 import/export - HTML, ODS,
Markdown, RTF, SVG, ODP formats
---
src/docs/handlers.rs | 61 ++++++-
src/docs/mod.rs | 13 +-
src/docs/utils.rs | 302 ++++++++++++++++++++++++++++++++
src/sheet/export.rs | 228 ++++++++++++++++++++++++
src/sheet/handlers.rs | 73 +++++++-
src/sheet/storage.rs | 234 +++++++++++++++++++++++++
src/slides/handlers.rs | 107 +++++++++++-
src/slides/mod.rs | 3 +-
src/slides/utils.rs | 389 ++++++++++++++++++++++++++++++++++++++++-
9 files changed, 1391 insertions(+), 19 deletions(-)
diff --git a/src/docs/handlers.rs b/src/docs/handlers.rs
index 35cf7935e..b39655721 100644
--- a/src/docs/handlers.rs
+++ b/src/docs/handlers.rs
@@ -6,7 +6,7 @@ use crate::docs::types::{
DocsSaveRequest, DocsSaveResponse, DocsAiRequest, DocsAiResponse, Document, DocumentMetadata,
SearchQuery, TemplateResponse,
};
-use crate::docs::utils::{html_to_markdown, strip_html};
+use crate::docs::utils::{convert_to_html, detect_document_format, html_to_markdown, markdown_to_html, rtf_to_html, strip_html};
use crate::docs::types::{
AcceptRejectAllRequest, AcceptRejectChangeRequest, AddCommentRequest, AddEndnoteRequest,
AddFootnoteRequest, ApplyStyleRequest, CompareDocumentsRequest, CompareDocumentsResponse,
@@ -1575,6 +1575,65 @@ pub async fn handle_get_outline(
Ok(Json(OutlineResponse { items }))
}
+pub async fn handle_import_document(
+ State(state): State>,
+ mut multipart: axum::extract::Multipart,
+) -> Result, (StatusCode, Json)> {
+ let mut file_bytes: Option> = None;
+ let mut filename = "import.docx".to_string();
+
+ while let Ok(Some(field)) = multipart.next_field().await {
+ if field.name() == Some("file") {
+ filename = field.file_name().unwrap_or("import.docx").to_string();
+ if let Ok(bytes) = field.bytes().await {
+ file_bytes = Some(bytes.to_vec());
+ }
+ }
+ }
+
+ let bytes = file_bytes.ok_or_else(|| {
+ (
+ StatusCode::BAD_REQUEST,
+ Json(serde_json::json!({ "error": "No file uploaded" })),
+ )
+ })?;
+
+ let format = detect_document_format(&bytes);
+ let content = match format {
+ "rtf" => rtf_to_html(&String::from_utf8_lossy(&bytes)),
+ "html" => String::from_utf8_lossy(&bytes).to_string(),
+ "markdown" => markdown_to_html(&String::from_utf8_lossy(&bytes)),
+ "txt" => {
+ let text = String::from_utf8_lossy(&bytes);
+ format!("{}
", text.replace('\n', "
"))
+ }
+ _ => {
+ return Err((
+ StatusCode::BAD_REQUEST,
+ Json(serde_json::json!({ "error": format!("Unsupported format: {}", format) })),
+ ))
+ }
+ };
+
+ let title = filename.rsplit('/').next().unwrap_or(&filename)
+ .rsplit('.').last().unwrap_or(&filename)
+ .to_string();
+
+ let user_id = get_current_user_id();
+ let mut doc = create_new_document(&title);
+ doc.content = content;
+ doc.owner_id = user_id.clone();
+
+ if let Err(e) = save_document_to_drive(&state, &user_id, &doc).await {
+ return Err((
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(serde_json::json!({ "error": e })),
+ ));
+ }
+
+ Ok(Json(doc))
+}
+
pub async fn handle_compare_documents(
State(state): State>,
Json(req): Json,
diff --git a/src/docs/mod.rs b/src/docs/mod.rs
index a8fc61865..2864b4c19 100644
--- a/src/docs/mod.rs
+++ b/src/docs/mod.rs
@@ -21,12 +21,12 @@ pub use handlers::{
handle_delete_endnote, handle_delete_footnote, handle_delete_style, handle_docs_ai,
handle_docs_get_by_id, handle_docs_save, handle_enable_track_changes, handle_export_docx,
handle_export_html, handle_export_md, handle_export_pdf, handle_export_txt,
- handle_generate_toc, handle_get_document, handle_get_outline, handle_list_comments,
- handle_list_documents, handle_list_endnotes, handle_list_footnotes, handle_list_styles,
- handle_list_track_changes, handle_new_document, handle_reply_comment, handle_resolve_comment,
- handle_save_document, handle_search_documents, handle_template_blank, handle_template_letter,
- handle_template_meeting, handle_template_report, handle_update_endnote, handle_update_footnote,
- handle_update_style, handle_update_toc,
+ handle_generate_toc, handle_get_document, handle_get_outline, handle_import_document,
+ handle_list_comments, handle_list_documents, handle_list_endnotes, handle_list_footnotes,
+ handle_list_styles, handle_list_track_changes, handle_new_document, handle_reply_comment,
+ handle_resolve_comment, handle_save_document, handle_search_documents, handle_template_blank,
+ handle_template_letter, handle_template_meeting, handle_template_report, handle_update_endnote,
+ handle_update_footnote, handle_update_style, handle_update_toc,
};
pub use types::{
AiRequest, AiResponse, Collaborator, CollabMessage, CommentReply, ComparisonSummary, Document,
@@ -61,6 +61,7 @@ pub fn configure_docs_routes() -> Router> {
.route("/api/docs/export/md", get(handle_export_md))
.route("/api/docs/export/html", get(handle_export_html))
.route("/api/docs/export/txt", get(handle_export_txt))
+ .route("/api/docs/import", post(handle_import_document))
.route("/api/docs/comment", post(handle_add_comment))
.route("/api/docs/comment/reply", post(handle_reply_comment))
.route("/api/docs/comment/resolve", post(handle_resolve_comment))
diff --git a/src/docs/utils.rs b/src/docs/utils.rs
index 3670368e4..d2316a937 100644
--- a/src/docs/utils.rs
+++ b/src/docs/utils.rs
@@ -1,4 +1,5 @@
use chrono::{DateTime, Duration, Utc};
+use std::collections::HashMap;
pub fn format_document_list_item(
id: &str,
@@ -269,3 +270,304 @@ pub fn generate_document_id() -> String {
pub fn get_user_docs_path(user_id: &str) -> String {
format!("users/{}/docs", user_id)
}
+
+pub fn rtf_to_html(rtf: &str) -> String {
+ let mut html = String::new();
+ let mut in_group = 0;
+ let mut bold = false;
+ let mut italic = false;
+ let mut underline = false;
+ let mut skip_chars = 0;
+ let chars: Vec = rtf.chars().collect();
+ let mut i = 0;
+
+ html.push_str("");
+
+ while i < chars.len() {
+ if skip_chars > 0 {
+ skip_chars -= 1;
+ i += 1;
+ continue;
+ }
+
+ let ch = chars[i];
+
+ match ch {
+ '{' => in_group += 1,
+ '}' => in_group -= 1,
+ '\\' => {
+ let mut cmd = String::new();
+ i += 1;
+ while i < chars.len() && chars[i].is_ascii_alphabetic() {
+ cmd.push(chars[i]);
+ i += 1;
+ }
+
+ match cmd.as_str() {
+ "b" => {
+ if !bold {
+ html.push_str("");
+ bold = true;
+ }
+ }
+ "b0" => {
+ if bold {
+ html.push_str("");
+ bold = false;
+ }
+ }
+ "i" => {
+ if !italic {
+ html.push_str("");
+ italic = true;
+ }
+ }
+ "i0" => {
+ if italic {
+ html.push_str("");
+ italic = false;
+ }
+ }
+ "ul" => {
+ if !underline {
+ html.push_str("");
+ underline = true;
+ }
+ }
+ "ulnone" => {
+ if underline {
+ html.push_str("");
+ underline = false;
+ }
+ }
+ "par" | "line" => html.push_str("
"),
+ "tab" => html.push_str(" "),
+ _ => {}
+ }
+
+ if i < chars.len() && chars[i] == ' ' {
+ i += 1;
+ }
+ continue;
+ }
+ '\n' | '\r' => {}
+ _ => {
+ if in_group <= 1 {
+ html.push(ch);
+ }
+ }
+ }
+ i += 1;
+ }
+
+ if underline {
+ html.push_str("");
+ }
+ if italic {
+ html.push_str("");
+ }
+ if bold {
+ html.push_str("");
+ }
+
+ html.push_str("
");
+ html
+}
+
+pub fn html_to_rtf(html: &str) -> String {
+ let mut rtf = String::from("{\\rtf1\\ansi\\deff0\n");
+ rtf.push_str("{\\fonttbl{\\f0 Arial;}}\n");
+ rtf.push_str("\\f0\\fs24\n");
+
+ let plain = strip_html(html);
+
+ let mut result = html.to_string();
+ result = result.replace("", "\\b ");
+ result = result.replace("", "\\b0 ");
+ result = result.replace("", "\\b ");
+ result = result.replace("", "\\b0 ");
+ result = result.replace("", "\\i ");
+ result = result.replace("", "\\i0 ");
+ result = result.replace("", "\\i ");
+ result = result.replace("", "\\i0 ");
+ result = result.replace("", "\\ul ");
+ result = result.replace("", "\\ulnone ");
+ result = result.replace("
", "\\par\n");
+ result = result.replace("
", "\\par\n");
+ result = result.replace("
", "\\par\n");
+ result = result.replace("", "");
+ result = result.replace("
", "\\par\\par\n");
+ result = result.replace("", "\\fs48\\b ");
+ result = result.replace("
", "\\b0\\fs24\\par\n");
+ result = result.replace("", "\\fs36\\b ");
+ result = result.replace("
", "\\b0\\fs24\\par\n");
+ result = result.replace("", "\\fs28\\b ");
+ result = result.replace("
", "\\b0\\fs24\\par\n");
+
+ let stripped = strip_html(&result);
+ rtf.push_str(&stripped);
+ rtf.push('}');
+ rtf
+}
+
+pub fn odt_content_to_html(odt_xml: &str) -> String {
+ let mut html = String::from("");
+
+ let mut in_text = false;
+ let mut in_span = false;
+ let mut current_text = String::new();
+ let chars: Vec
= odt_xml.chars().collect();
+ let mut i = 0;
+
+ while i < chars.len() {
+ if chars[i] == '<' {
+ let mut tag = String::new();
+ i += 1;
+ while i < chars.len() && chars[i] != '>' {
+ tag.push(chars[i]);
+ i += 1;
+ }
+
+ if tag.starts_with("text:p") {
+ if !current_text.is_empty() {
+ html.push_str(¤t_text);
+ current_text.clear();
+ }
+ html.push_str("");
+ in_text = true;
+ } else if tag == "/text:p" {
+ html.push_str(¤t_text);
+ current_text.clear();
+ html.push_str("
");
+ in_text = false;
+ } else if tag.starts_with("text:span") {
+ if tag.contains("Bold") {
+ html.push_str("");
+ } else if tag.contains("Italic") {
+ html.push_str("");
+ }
+ in_span = true;
+ } else if tag == "/text:span" {
+ html.push_str(¤t_text);
+ current_text.clear();
+ if in_span {
+ html.push_str("");
+ }
+ in_span = false;
+ } else if tag.starts_with("text:h") {
+ let level = tag.chars()
+ .find(|c| c.is_ascii_digit())
+ .unwrap_or('1');
+ html.push_str(&format!(""));
+ in_text = true;
+ } else if tag.starts_with("/text:h") {
+ html.push_str(¤t_text);
+ current_text.clear();
+ html.push_str("");
+ in_text = false;
+ } else if tag == "text:line-break" || tag == "text:line-break/" {
+ current_text.push_str("
");
+ } else if tag == "text:tab" || tag == "text:tab/" {
+ current_text.push_str(" ");
+ }
+ } else if in_text {
+ current_text.push(chars[i]);
+ }
+ i += 1;
+ }
+
+ if !current_text.is_empty() {
+ html.push_str(¤t_text);
+ }
+
+ html.push_str(" ");
+ html
+}
+
+pub fn html_to_odt_content(html: &str) -> String {
+ let mut odt = String::from(r#"
+
+
+
+"#);
+
+ let mut result = html.to_string();
+ result = result.replace("", "");
+ result = result.replace("
", "\n");
+ result = result.replace("
", "");
+ result = result.replace("
", "");
+ result = result.replace("
", "");
+ result = result.replace("", "");
+ result = result.replace("", "");
+ result = result.replace("", "");
+ result = result.replace("", "");
+ result = result.replace("", "");
+ result = result.replace("", "");
+ result = result.replace("", "");
+ result = result.replace("", "");
+ result = result.replace("", "");
+ result = result.replace("
", "\n");
+ result = result.replace("", "");
+ result = result.replace("
", "\n");
+ result = result.replace("", "");
+ result = result.replace("
", "\n");
+
+ let stripped = strip_html(&result);
+ let paragraphs: Vec<&str> = stripped.lines().collect();
+ for para in paragraphs {
+ if !para.trim().is_empty() {
+ odt.push_str(&format!("{}\n", para.trim()));
+ }
+ }
+
+ odt.push_str("\n\n");
+ odt
+}
+
+pub fn detect_document_format(content: &[u8]) -> &'static str {
+ if content.len() >= 4 {
+ if &content[0..4] == b"PK\x03\x04" {
+ if content.len() > 30 {
+ let content_str = String::from_utf8_lossy(&content[0..100.min(content.len())]);
+ if content_str.contains("word/") {
+ return "docx";
+ } else if content_str.contains("content.xml") {
+ return "odt";
+ }
+ }
+ return "zip";
+ }
+ if &content[0..4] == b"{\\rt" {
+ return "rtf";
+ }
+ if content[0] == 0xD0 && content[1] == 0xCF {
+ return "doc";
+ }
+ }
+
+ let text = String::from_utf8_lossy(content);
+ if text.trim_start().starts_with(" Result {
+ let format = detect_document_format(content);
+ let text = String::from_utf8_lossy(content).to_string();
+
+ match format {
+ "rtf" => Ok(rtf_to_html(&text)),
+ "html" => Ok(text),
+ "markdown" => Ok(markdown_to_html(&text)),
+ "txt" => Ok(format!("{}
", html_escape(&text).replace('\n', "
"))),
+ _ => Err(format!("Unsupported format: {format}")),
+ }
+}
diff --git a/src/sheet/export.rs b/src/sheet/export.rs
index 9347a0bbb..c38d47d3e 100644
--- a/src/sheet/export.rs
+++ b/src/sheet/export.rs
@@ -1,6 +1,7 @@
use base64::Engine;
use crate::sheet::types::{CellStyle, Spreadsheet};
use rust_xlsxwriter::{Color, Format, FormatAlign, Workbook};
+use std::io::Cursor;
pub fn export_to_xlsx(sheet: &Spreadsheet) -> Result {
let mut workbook = Workbook::new();
@@ -160,3 +161,230 @@ pub fn export_to_csv(sheet: &Spreadsheet) -> String {
pub fn export_to_json(sheet: &Spreadsheet) -> String {
serde_json::to_string_pretty(sheet).unwrap_or_default()
}
+
+pub fn export_to_html(sheet: &Spreadsheet) -> String {
+ let mut html = String::from(r#"
+
+
+
+
+ "#);
+ html.push_str(&sheet.name);
+ html.push_str(r#"
+
+
+
+"#);
+
+ for (ws_idx, ws) in sheet.worksheets.iter().enumerate() {
+ html.push_str(&format!("{}
\n", ws.name));
+ html.push_str("\n");
+
+ let mut max_row: u32 = 0;
+ let mut max_col: u32 = 0;
+ for key in ws.data.keys() {
+ let parts: Vec<&str> = key.split(',').collect();
+ if parts.len() == 2 {
+ if let (Ok(row), Ok(col)) = (parts[0].parse::(), parts[1].parse::()) {
+ max_row = max_row.max(row);
+ max_col = max_col.max(col);
+ }
+ }
+ }
+
+ html.push_str(" | ");
+ for col in 0..=max_col {
+ let col_letter = column_to_letter(col);
+ html.push_str(&format!("{col_letter} | "));
+ }
+ html.push_str("
\n\n");
+
+ for row in 0..=max_row {
+ html.push_str(&format!("| {} | ", row + 1));
+ for col in 0..=max_col {
+ let key = format!("{row},{col}");
+ let cell = ws.data.get(&key);
+ let value = cell.and_then(|c| c.value.clone()).unwrap_or_default();
+ let style = cell.and_then(|c| c.style.as_ref());
+
+ let mut style_str = String::new();
+ if let Some(s) = style {
+ if let Some(ref bg) = s.background {
+ style_str.push_str(&format!("background-color:{bg};"));
+ }
+ if let Some(ref color) = s.color {
+ style_str.push_str(&format!("color:{color};"));
+ }
+ if let Some(ref weight) = s.font_weight {
+ style_str.push_str(&format!("font-weight:{weight};"));
+ }
+ if let Some(ref align) = s.text_align {
+ style_str.push_str(&format!("text-align:{align};"));
+ }
+ }
+
+ let escaped_value = html_escape(&value);
+ if style_str.is_empty() {
+ html.push_str(&format!("{escaped_value} | "));
+ } else {
+ html.push_str(&format!("{escaped_value} | "));
+ }
+ }
+ html.push_str("
\n");
+ }
+ html.push_str("
\n");
+ }
+
+ html.push_str("");
+ html
+}
+
+fn column_to_letter(col: u32) -> String {
+ let mut result = String::new();
+ let mut n = col + 1;
+ while n > 0 {
+ n -= 1;
+ result.insert(0, (b'A' + (n % 26) as u8) as char);
+ n /= 26;
+ }
+ result
+}
+
+fn html_escape(s: &str) -> String {
+ s.replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+ .replace('\'', "'")
+}
+
+pub fn export_to_ods(sheet: &Spreadsheet) -> Result {
+ let mut xml = String::from(r#"
+
+
+
+"#);
+
+ for ws in &sheet.worksheets {
+ xml.push_str(&format!("\n", ws.name));
+
+ let mut max_row: u32 = 0;
+ let mut max_col: u32 = 0;
+ for key in ws.data.keys() {
+ let parts: Vec<&str> = key.split(',').collect();
+ if parts.len() == 2 {
+ if let (Ok(row), Ok(col)) = (parts[0].parse::(), parts[1].parse::()) {
+ max_row = max_row.max(row);
+ max_col = max_col.max(col);
+ }
+ }
+ }
+
+ for _ in 0..=max_col {
+ xml.push_str("\n");
+ }
+
+ for row in 0..=max_row {
+ xml.push_str("\n");
+ for col in 0..=max_col {
+ let key = format!("{row},{col}");
+ let value = ws.data.get(&key).and_then(|c| c.value.clone()).unwrap_or_default();
+ let formula = ws.data.get(&key).and_then(|c| c.formula.clone());
+
+ if let Some(f) = formula {
+ xml.push_str(&format!(
+ "\n{}\n\n",
+ f, value
+ ));
+ } else if let Ok(num) = value.parse::() {
+ xml.push_str(&format!(
+ "\n{}\n\n",
+ num, value
+ ));
+ } else {
+ xml.push_str(&format!(
+ "\n{}\n\n",
+ value
+ ));
+ }
+ }
+ xml.push_str("\n");
+ }
+ xml.push_str("\n");
+ }
+
+ xml.push_str("\n\n");
+ Ok(xml)
+}
+
+pub fn export_to_pdf_data(sheet: &Spreadsheet) -> Result, String> {
+ let html = export_to_html(sheet);
+ Ok(html.into_bytes())
+}
+
+pub fn export_to_markdown(sheet: &Spreadsheet) -> String {
+ let mut md = String::new();
+ md.push_str(&format!("# {}\n\n", sheet.name));
+
+ for ws in &sheet.worksheets {
+ md.push_str(&format!("## {}\n\n", ws.name));
+
+ let mut max_row: u32 = 0;
+ let mut max_col: u32 = 0;
+ for key in ws.data.keys() {
+ let parts: Vec<&str> = key.split(',').collect();
+ if parts.len() == 2 {
+ if let (Ok(row), Ok(col)) = (parts[0].parse::(), parts[1].parse::()) {
+ max_row = max_row.max(row);
+ max_col = max_col.max(col);
+ }
+ }
+ }
+
+ if max_col == 0 && max_row == 0 && ws.data.is_empty() {
+ md.push_str("*Empty worksheet*\n\n");
+ continue;
+ }
+
+ md.push('|');
+ for col in 0..=max_col {
+ let col_letter = column_to_letter(col);
+ md.push_str(&format!(" {col_letter} |"));
+ }
+ md.push('\n');
+
+ md.push('|');
+ for _ in 0..=max_col {
+ md.push_str(" --- |");
+ }
+ md.push('\n');
+
+ for row in 0..=max_row {
+ md.push('|');
+ for col in 0..=max_col {
+ let key = format!("{row},{col}");
+ let value = ws.data.get(&key).and_then(|c| c.value.clone()).unwrap_or_default();
+ let escaped = value.replace('|', "\\|");
+ md.push_str(&format!(" {escaped} |"));
+ }
+ md.push('\n');
+ }
+ md.push('\n');
+ }
+
+ md
+}
diff --git a/src/sheet/handlers.rs b/src/sheet/handlers.rs
index f7fd7ca7b..c7473a8c5 100644
--- a/src/sheet/handlers.rs
+++ b/src/sheet/handlers.rs
@@ -1,11 +1,11 @@
use crate::shared::state::AppState;
use crate::sheet::collaboration::broadcast_sheet_change;
-use crate::sheet::export::{export_to_csv, export_to_json, export_to_xlsx};
+use crate::sheet::export::{export_to_csv, export_to_html, export_to_json, export_to_markdown, export_to_ods, export_to_xlsx};
use crate::sheet::formulas::evaluate_formula;
use crate::sheet::storage::{
- create_new_spreadsheet, delete_sheet_from_drive, get_current_user_id, list_sheets_from_drive,
- load_sheet_by_id, load_sheet_from_drive, parse_csv_to_worksheets, parse_excel_to_worksheets,
- save_sheet_to_drive,
+ create_new_spreadsheet, delete_sheet_from_drive, get_current_user_id, import_spreadsheet_bytes,
+ list_sheets_from_drive, load_sheet_by_id, load_sheet_from_drive, parse_csv_to_worksheets,
+ parse_excel_to_worksheets, save_sheet_to_drive,
};
use crate::sheet::types::{
AddCommentRequest, AddExternalLinkRequest, AddNoteRequest, ArrayFormula, ArrayFormulaRequest,
@@ -472,6 +472,29 @@ pub async fn handle_export_sheet(
let json = export_to_json(&sheet);
Ok(([(axum::http::header::CONTENT_TYPE, "application/json")], json))
}
+ "html" => {
+ let html = export_to_html(&sheet);
+ Ok(([(axum::http::header::CONTENT_TYPE, "text/html")], html))
+ }
+ "ods" => {
+ let ods = export_to_ods(&sheet).map_err(|e| {
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(serde_json::json!({ "error": e })),
+ )
+ })?;
+ Ok((
+ [(
+ axum::http::header::CONTENT_TYPE,
+ "application/vnd.oasis.opendocument.spreadsheet",
+ )],
+ ods,
+ ))
+ }
+ "md" | "markdown" => {
+ let md = export_to_markdown(&sheet);
+ Ok(([(axum::http::header::CONTENT_TYPE, "text/markdown")], md))
+ }
_ => Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "Unsupported format" })),
@@ -1158,10 +1181,46 @@ pub async fn handle_add_note(
}
pub async fn handle_import_sheet(
- State(_state): State>,
- mut _multipart: axum::extract::Multipart,
+ State(state): State>,
+ mut multipart: axum::extract::Multipart,
) -> Result, (StatusCode, Json)> {
- Ok(Json(create_new_spreadsheet()))
+ let mut file_bytes: Option> = None;
+ let mut filename = "import.xlsx".to_string();
+
+ while let Ok(Some(field)) = multipart.next_field().await {
+ if field.name() == Some("file") {
+ filename = field.file_name().unwrap_or("import.xlsx").to_string();
+ if let Ok(bytes) = field.bytes().await {
+ file_bytes = Some(bytes.to_vec());
+ }
+ }
+ }
+
+ let bytes = file_bytes.ok_or_else(|| {
+ (
+ StatusCode::BAD_REQUEST,
+ Json(serde_json::json!({ "error": "No file uploaded" })),
+ )
+ })?;
+
+ let mut sheet = import_spreadsheet_bytes(&bytes, &filename).map_err(|e| {
+ (
+ StatusCode::BAD_REQUEST,
+ Json(serde_json::json!({ "error": e })),
+ )
+ })?;
+
+ let user_id = get_current_user_id();
+ sheet.owner_id = user_id.clone();
+
+ if let Err(e) = save_sheet_to_drive(&state, &user_id, &sheet).await {
+ return Err((
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(serde_json::json!({ "error": e })),
+ ));
+ }
+
+ Ok(Json(sheet))
}
pub async fn handle_add_comment(
diff --git a/src/sheet/storage.rs b/src/sheet/storage.rs
index 44da4c78d..e59fba809 100644
--- a/src/sheet/storage.rs
+++ b/src/sheet/storage.rs
@@ -778,6 +778,9 @@ pub fn parse_csv_to_worksheets(
style: None,
format: None,
note: None,
+ locked: None,
+ has_comment: None,
+ array_formula_id: None,
},
);
}
@@ -797,6 +800,9 @@ pub fn parse_csv_to_worksheets(
validations: None,
conditional_formats: None,
charts: None,
+ comments: None,
+ protection: None,
+ array_formulas: None,
}])
}
@@ -835,6 +841,9 @@ pub fn parse_excel_to_worksheets(bytes: &[u8], ext: &str) -> Result Result Result Result, String> {
+ let content = String::from_utf8_lossy(bytes);
+ let mut worksheets = Vec::new();
+ let mut current_sheet_name = "Sheet1".to_string();
+ let mut data: HashMap = HashMap::new();
+ let mut row_idx = 0u32;
+
+ let mut in_table = false;
+ let mut in_row = false;
+ let mut col_idx = 0u32;
+
+ let chars: Vec = content.chars().collect();
+ let mut i = 0;
+
+ while i < chars.len() {
+ if chars[i] == '<' {
+ let mut tag = String::new();
+ i += 1;
+ while i < chars.len() && chars[i] != '>' {
+ tag.push(chars[i]);
+ i += 1;
+ }
+
+ if tag.starts_with("table:table ") {
+ if let Some(name_start) = tag.find("table:name=\"") {
+ let name_part = &tag[name_start + 12..];
+ if let Some(name_end) = name_part.find('"') {
+ current_sheet_name = name_part[..name_end].to_string();
+ }
+ }
+ in_table = true;
+ data.clear();
+ row_idx = 0;
+ } else if tag == "/table:table" {
+ if in_table && !data.is_empty() {
+ worksheets.push(Worksheet {
+ name: current_sheet_name.clone(),
+ data: data.clone(),
+ column_widths: None,
+ row_heights: None,
+ frozen_rows: None,
+ frozen_cols: None,
+ merged_cells: None,
+ filters: None,
+ hidden_rows: None,
+ validations: None,
+ conditional_formats: None,
+ charts: None,
+ comments: None,
+ protection: None,
+ array_formulas: None,
+ });
+ }
+ in_table = false;
+ } else if tag.starts_with("table:table-row") && !tag.ends_with('/') {
+ in_row = true;
+ col_idx = 0;
+ } else if tag == "/table:table-row" {
+ in_row = false;
+ row_idx += 1;
+ } else if tag.starts_with("table:table-cell") {
+ let mut cell_value = String::new();
+ let mut has_formula = false;
+ let mut formula = String::new();
+
+ if tag.contains("table:formula=") {
+ has_formula = true;
+ if let Some(f_start) = tag.find("table:formula=\"") {
+ let f_part = &tag[f_start + 15..];
+ if let Some(f_end) = f_part.find('"') {
+ formula = f_part[..f_end].to_string();
+ }
+ }
+ }
+
+ if tag.contains("office:value=") {
+ if let Some(v_start) = tag.find("office:value=\"") {
+ let v_part = &tag[v_start + 14..];
+ if let Some(v_end) = v_part.find('"') {
+ cell_value = v_part[..v_end].to_string();
+ }
+ }
+ }
+
+ i += 1;
+ let mut text_depth = 0;
+ while i < chars.len() {
+ if chars[i] == '<' {
+ let mut inner_tag = String::new();
+ i += 1;
+ while i < chars.len() && chars[i] != '>' {
+ inner_tag.push(chars[i]);
+ i += 1;
+ }
+ if inner_tag.starts_with("text:p") {
+ text_depth += 1;
+ } else if inner_tag == "/text:p" {
+ text_depth -= 1;
+ } else if inner_tag == "/table:table-cell" {
+ break;
+ }
+ } else if text_depth > 0 {
+ cell_value.push(chars[i]);
+ }
+ i += 1;
+ }
+
+ if !cell_value.is_empty() || has_formula {
+ let key = format!("{row_idx},{col_idx}");
+ data.insert(key, CellData {
+ value: if cell_value.is_empty() { None } else { Some(cell_value) },
+ formula: if has_formula { Some(formula) } else { None },
+ style: None,
+ format: None,
+ note: None,
+ locked: None,
+ has_comment: None,
+ array_formula_id: None,
+ });
+ }
+
+ col_idx += 1;
+ }
+ }
+ i += 1;
+ }
+
+ if worksheets.is_empty() {
+ worksheets.push(Worksheet {
+ name: "Sheet1".to_string(),
+ data: HashMap::new(),
+ column_widths: None,
+ row_heights: None,
+ frozen_rows: None,
+ frozen_cols: None,
+ merged_cells: None,
+ filters: None,
+ hidden_rows: None,
+ validations: None,
+ conditional_formats: None,
+ charts: None,
+ comments: None,
+ protection: None,
+ array_formulas: None,
+ });
+ }
+
+ Ok(worksheets)
+}
+
+pub fn detect_spreadsheet_format(bytes: &[u8]) -> &'static str {
+ if bytes.len() >= 4 {
+ if &bytes[0..4] == b"PK\x03\x04" {
+ let content_str = String::from_utf8_lossy(&bytes[0..500.min(bytes.len())]);
+ if content_str.contains("xl/") || content_str.contains("[Content_Types].xml") {
+ return "xlsx";
+ }
+ if content_str.contains("content.xml") || content_str.contains("mimetype") {
+ return "ods";
+ }
+ return "zip";
+ }
+ if bytes[0] == 0xD0 && bytes[1] == 0xCF {
+ return "xls";
+ }
+ }
+
+ let text = String::from_utf8_lossy(&bytes[0..100.min(bytes.len())]);
+ if text.contains('\t') && text.lines().count() > 1 {
+ return "tsv";
+ }
+ if text.contains(',') && text.lines().count() > 1 {
+ return "csv";
+ }
+
+ "unknown"
+}
+
+pub fn import_spreadsheet_bytes(bytes: &[u8], filename: &str) -> Result {
+ let ext = filename.rsplit('.').next().unwrap_or("").to_lowercase();
+ let detected = detect_spreadsheet_format(bytes);
+
+ let worksheets = match detected {
+ "xlsx" | "xlsm" => parse_excel_to_worksheets(bytes, "xlsx")?,
+ "xls" => parse_excel_to_worksheets(bytes, "xls")?,
+ "ods" => parse_ods_to_worksheets(bytes)?,
+ "csv" => parse_csv_to_worksheets(bytes, b',', "Sheet1")?,
+ "tsv" => parse_csv_to_worksheets(bytes, b'\t', "Sheet1")?,
+ _ => {
+ if ext == "csv" {
+ parse_csv_to_worksheets(bytes, b',', "Sheet1")?
+ } else if ext == "tsv" || ext == "txt" {
+ parse_csv_to_worksheets(bytes, b'\t', "Sheet1")?
+ } else if ext == "ods" {
+ parse_ods_to_worksheets(bytes)?
+ } else {
+ return Err(format!("Unsupported format: {detected}"));
+ }
+ }
+ };
+
+ let name = filename.rsplit('/').next().unwrap_or(filename)
+ .trim_end_matches(&format!(".{ext}"))
+ .to_string();
+
+ Ok(Spreadsheet {
+ id: Uuid::new_v4().to_string(),
+ name,
+ owner_id: get_current_user_id(),
+ worksheets,
+ created_at: Utc::now(),
+ updated_at: Utc::now(),
+ named_ranges: None,
+ external_links: None,
+ })
+}
+
pub fn create_new_spreadsheet() -> Spreadsheet {
Spreadsheet {
id: Uuid::new_v4().to_string(),
@@ -884,8 +1113,13 @@ pub fn create_new_spreadsheet() -> Spreadsheet {
validations: None,
conditional_formats: None,
charts: None,
+ comments: None,
+ protection: None,
+ array_formulas: None,
}],
created_at: Utc::now(),
updated_at: Utc::now(),
+ named_ranges: None,
+ external_links: None,
}
}
diff --git a/src/slides/handlers.rs b/src/slides/handlers.rs
index 2a31bd6d9..a5d3325b6 100644
--- a/src/slides/handlers.rs
+++ b/src/slides/handlers.rs
@@ -5,6 +5,7 @@ use crate::slides::storage::{
get_current_user_id, list_presentations_from_drive, load_presentation_by_id,
load_presentation_from_drive, save_presentation_to_drive,
};
+use crate::slides::utils::slides_from_markdown;
use crate::slides::types::{
AddElementRequest, AddMediaRequest, AddSlideRequest, ApplyThemeRequest,
ApplyTransitionToAllRequest, CollaborationCursor, CollaborationSelection, DeleteElementRequest,
@@ -16,7 +17,7 @@ use crate::slides::types::{
SlidesAiResponse, StartPresenterRequest, UpdateCursorRequest, UpdateElementRequest,
UpdateMediaRequest, UpdatePresenterRequest, UpdateSelectionRequest, UpdateSlideNotesRequest,
};
-use crate::slides::utils::export_to_html;
+use crate::slides::utils::{create_default_theme, export_to_html, export_to_json, export_to_markdown, export_to_odp_content, export_to_svg, slides_from_markdown};
use axum::{
extract::{Path, Query, State},
http::StatusCode,
@@ -611,9 +612,35 @@ pub async fn handle_export_presentation(
Ok(([(axum::http::header::CONTENT_TYPE, "text/html")], html))
}
"json" => {
- let json = serde_json::to_string_pretty(&presentation).unwrap_or_default();
+ let json = export_to_json(&presentation);
Ok(([(axum::http::header::CONTENT_TYPE, "application/json")], json))
}
+ "svg" => {
+ let slide_idx = 0;
+ if slide_idx < presentation.slides.len() {
+ let svg = export_to_svg(&presentation.slides[slide_idx], 960, 540);
+ Ok(([(axum::http::header::CONTENT_TYPE, "image/svg+xml")], svg))
+ } else {
+ Err((
+ StatusCode::BAD_REQUEST,
+ Json(serde_json::json!({ "error": "No slides to export" })),
+ ))
+ }
+ }
+ "md" | "markdown" => {
+ let md = export_to_markdown(&presentation);
+ Ok(([(axum::http::header::CONTENT_TYPE, "text/markdown")], md))
+ }
+ "odp" => {
+ let odp = export_to_odp_content(&presentation);
+ Ok((
+ [(
+ axum::http::header::CONTENT_TYPE,
+ "application/vnd.oasis.opendocument.presentation",
+ )],
+ odp,
+ ))
+ }
"pptx" => {
Ok((
[(
@@ -1107,3 +1134,79 @@ pub async fn handle_get_presenter_notes(
next_slide_thumbnail: None,
}))
}
+
+pub async fn handle_import_presentation(
+ State(state): State>,
+ mut multipart: axum::extract::Multipart,
+) -> Result, (StatusCode, Json)> {
+ let mut file_bytes: Option> = None;
+ let mut filename = "import.pptx".to_string();
+
+ while let Ok(Some(field)) = multipart.next_field().await {
+ if field.name() == Some("file") {
+ filename = field.file_name().unwrap_or("import.pptx").to_string();
+ if let Ok(bytes) = field.bytes().await {
+ file_bytes = Some(bytes.to_vec());
+ }
+ }
+ }
+
+ let bytes = file_bytes.ok_or_else(|| {
+ (
+ StatusCode::BAD_REQUEST,
+ Json(serde_json::json!({ "error": "No file uploaded" })),
+ )
+ })?;
+
+ let ext = filename.rsplit('.').next().unwrap_or("").to_lowercase();
+ let theme = create_default_theme();
+
+ let slides = match ext.as_str() {
+ "md" | "markdown" => {
+ let content = String::from_utf8_lossy(&bytes);
+ slides_from_markdown(&content)
+ }
+ "json" => {
+ let pres: Result = serde_json::from_slice(&bytes);
+ match pres {
+ Ok(p) => p.slides,
+ Err(e) => {
+ return Err((
+ StatusCode::BAD_REQUEST,
+ Json(serde_json::json!({ "error": format!("Invalid JSON: {}", e) })),
+ ))
+ }
+ }
+ }
+ _ => {
+ return Err((
+ StatusCode::BAD_REQUEST,
+ Json(serde_json::json!({ "error": format!("Unsupported format: {}", ext) })),
+ ))
+ }
+ };
+
+ let name = filename.rsplit('/').next().unwrap_or(&filename)
+ .rsplit('.').last().unwrap_or(&filename)
+ .to_string();
+
+ let user_id = get_current_user_id();
+ let presentation = Presentation {
+ id: Uuid::new_v4().to_string(),
+ name,
+ owner_id: user_id.clone(),
+ slides,
+ theme,
+ created_at: Utc::now(),
+ updated_at: Utc::now(),
+ };
+
+ if let Err(e) = save_presentation_to_drive(&state, &user_id, &presentation).await {
+ return Err((
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(serde_json::json!({ "error": e })),
+ ));
+ }
+
+ Ok(Json(presentation))
+}
diff --git a/src/slides/mod.rs b/src/slides/mod.rs
index cf6e83002..35e0f1e93 100644
--- a/src/slides/mod.rs
+++ b/src/slides/mod.rs
@@ -18,7 +18,7 @@ pub use handlers::{
handle_apply_transition_to_all, handle_delete_element, handle_delete_media,
handle_delete_presentation, handle_delete_slide, handle_duplicate_slide,
handle_end_presenter, handle_export_presentation, handle_get_presentation_by_id,
- handle_get_presenter_notes, handle_list_cursors, handle_list_media,
+ handle_get_presenter_notes, handle_import_presentation, handle_list_cursors, handle_list_media,
handle_list_presentations, handle_list_selections, handle_load_presentation,
handle_new_presentation, handle_remove_transition, handle_reorder_slides,
handle_save_presentation, handle_search_presentations, handle_set_transition,
@@ -56,6 +56,7 @@ pub fn configure_slides_routes() -> Router> {
.route("/api/slides/element/delete", post(handle_delete_element))
.route("/api/slides/theme", post(handle_apply_theme))
.route("/api/slides/export", post(handle_export_presentation))
+ .route("/api/slides/import", post(handle_import_presentation))
.route("/api/slides/cursor", post(handle_update_cursor))
.route("/api/slides/selection", post(handle_update_selection))
.route("/api/slides/cursors", get(handle_list_cursors))
diff --git a/src/slides/utils.rs b/src/slides/utils.rs
index 193366f79..e06713ccd 100644
--- a/src/slides/utils.rs
+++ b/src/slides/utils.rs
@@ -1,7 +1,8 @@
use crate::slides::types::{
- ElementContent, ElementStyle, PresentationTheme, Slide, SlideBackground, SlideElement,
- ThemeColors, ThemeFonts,
+ ElementContent, ElementStyle, Presentation, PresentationTheme, Slide, SlideBackground,
+ SlideElement, ThemeColors, ThemeFonts,
};
+use base64::Engine;
use uuid::Uuid;
pub fn create_default_theme() -> PresentationTheme {
@@ -312,3 +313,387 @@ pub fn sanitize_filename(name: &str) -> String {
.trim_matches('_')
.to_string()
}
+
+pub fn export_to_svg(slide: &Slide, width: u32, height: u32) -> String {
+ let mut svg = format!(
+ r#"
+");
+ svg
+}
+
+fn xml_escape(s: &str) -> String {
+ s.replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+ .replace('\'', "'")
+}
+
+pub fn export_slide_to_png_placeholder(slide: &Slide, width: u32, height: u32) -> Vec {
+ let svg = export_to_svg(slide, width, height);
+ svg.into_bytes()
+}
+
+pub fn export_to_odp_content(presentation: &Presentation) -> String {
+ let mut xml = String::from(r#"
+
+
+
+"#);
+
+ for (idx, slide) in presentation.slides.iter().enumerate() {
+ xml.push_str(&format!(
+ "\n",
+ idx + 1
+ ));
+
+ for element in &slide.elements {
+ match element.element_type.as_str() {
+ "text" => {
+ let text = element.content.text.as_deref().unwrap_or("");
+ xml.push_str(&format!(
+ r#"
+
+ {}
+
+
+"#,
+ element.x, element.y, element.width, element.height, xml_escape(text)
+ ));
+ }
+ "shape" => {
+ let shape_type = element.content.shape_type.as_deref().unwrap_or("rectangle");
+ let fill = element.style.fill.as_deref().unwrap_or("#cccccc");
+
+ match shape_type {
+ "rectangle" | "rect" => {
+ xml.push_str(&format!(
+ r#"
+"#,
+ element.x, element.y, element.width, element.height, fill
+ ));
+ }
+ "circle" | "ellipse" => {
+ xml.push_str(&format!(
+ r#"
+"#,
+ element.x, element.y, element.width, element.height, fill
+ ));
+ }
+ _ => {}
+ }
+ }
+ "image" => {
+ if let Some(ref src) = element.content.src {
+ xml.push_str(&format!(
+ r#"
+
+
+"#,
+ element.x, element.y, element.width, element.height, src
+ ));
+ }
+ }
+ _ => {}
+ }
+ }
+
+ xml.push_str("\n");
+ }
+
+ xml.push_str("\n\n");
+ xml
+}
+
+pub fn export_to_json(presentation: &Presentation) -> String {
+ serde_json::to_string_pretty(presentation).unwrap_or_default()
+}
+
+pub fn export_to_markdown(presentation: &Presentation) -> String {
+ let mut md = format!("# {}\n\n", presentation.name);
+
+ for (idx, slide) in presentation.slides.iter().enumerate() {
+ md.push_str(&format!("---\n\n## Slide {}\n\n", idx + 1));
+
+ for element in &slide.elements {
+ if element.element_type == "text" {
+ if let Some(ref text) = element.content.text {
+ let font_size = element.style.font_size.unwrap_or(18.0);
+ if font_size >= 32.0 {
+ md.push_str(&format!("### {}\n\n", text));
+ } else {
+ md.push_str(&format!("{}\n\n", text));
+ }
+ }
+ } else if element.element_type == "image" {
+ if let Some(ref src) = element.content.src {
+ md.push_str(&format!("\n\n", src));
+ }
+ }
+ }
+
+ if let Some(ref notes) = slide.notes {
+ md.push_str(&format!("**Speaker Notes:**\n{}\n\n", notes));
+ }
+ }
+
+ md
+}
+
+pub fn slides_from_markdown(md: &str) -> Vec {
+ let theme = create_default_theme();
+ let mut slides = Vec::new();
+ let sections: Vec<&str> = md.split("\n---\n").collect();
+
+ for section in sections {
+ let lines: Vec<&str> = section.lines().filter(|l| !l.trim().is_empty()).collect();
+ if lines.is_empty() {
+ continue;
+ }
+
+ let mut slide = create_blank_slide(&theme);
+ let mut y_offset = 50.0;
+
+ for line in lines {
+ let trimmed = line.trim();
+ if trimmed.starts_with("# ") {
+ slide.elements.push(create_text_element(
+ &trimmed[2..],
+ 50.0,
+ y_offset,
+ 860.0,
+ 60.0,
+ 44.0,
+ true,
+ &theme,
+ ));
+ y_offset += 80.0;
+ } else if trimmed.starts_with("## ") {
+ slide.elements.push(create_text_element(
+ &trimmed[3..],
+ 50.0,
+ y_offset,
+ 860.0,
+ 50.0,
+ 32.0,
+ true,
+ &theme,
+ ));
+ y_offset += 60.0;
+ } else if trimmed.starts_with("### ") {
+ slide.elements.push(create_text_element(
+ &trimmed[4..],
+ 50.0,
+ y_offset,
+ 860.0,
+ 40.0,
+ 24.0,
+ true,
+ &theme,
+ ));
+ y_offset += 50.0;
+ } else if trimmed.starts_with("![") {
+ if let Some(start) = trimmed.find('(') {
+ if let Some(end) = trimmed.find(')') {
+ let src = &trimmed[start + 1..end];
+ slide.elements.push(SlideElement {
+ id: Uuid::new_v4().to_string(),
+ element_type: "image".to_string(),
+ x: 50.0,
+ y: y_offset,
+ width: 400.0,
+ height: 300.0,
+ rotation: 0.0,
+ content: ElementContent {
+ text: None,
+ html: None,
+ src: Some(src.to_string()),
+ shape_type: None,
+ chart_data: None,
+ table_data: None,
+ },
+ style: ElementStyle::default(),
+ animations: vec![],
+ z_index: slide.elements.len() as i32,
+ locked: false,
+ });
+ y_offset += 320.0;
+ }
+ }
+ } else if !trimmed.is_empty() {
+ slide.elements.push(create_text_element(
+ trimmed,
+ 50.0,
+ y_offset,
+ 860.0,
+ 30.0,
+ 18.0,
+ false,
+ &theme,
+ ));
+ y_offset += 40.0;
+ }
+ }
+
+ slides.push(slide);
+ }
+
+ if slides.is_empty() {
+ slides.push(create_title_slide(&theme));
+ }
+
+ slides
+}
+
+fn create_text_element(
+ text: &str,
+ x: f64,
+ y: f64,
+ width: f64,
+ height: f64,
+ font_size: f64,
+ bold: bool,
+ theme: &PresentationTheme,
+) -> SlideElement {
+ SlideElement {
+ id: Uuid::new_v4().to_string(),
+ element_type: "text".to_string(),
+ x,
+ y,
+ width,
+ height,
+ rotation: 0.0,
+ content: ElementContent {
+ text: Some(text.to_string()),
+ html: Some(format!("{}
", text)),
+ src: None,
+ shape_type: None,
+ chart_data: None,
+ table_data: None,
+ },
+ style: ElementStyle {
+ fill: None,
+ stroke: None,
+ stroke_width: None,
+ opacity: None,
+ shadow: None,
+ font_family: Some(theme.fonts.body.clone()),
+ font_size: Some(font_size),
+ font_weight: if bold { Some("bold".to_string()) } else { None },
+ font_style: None,
+ text_align: Some("left".to_string()),
+ vertical_align: Some("top".to_string()),
+ color: Some(theme.colors.text.clone()),
+ line_height: None,
+ border_radius: None,
+ },
+ animations: vec![],
+ z_index: 0,
+ locked: false,
+ }
+}