feat(office): Add Phase 4 import/export - HTML, ODS, Markdown, RTF, SVG, ODP formats

This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2026-01-11 12:22:14 -03:00
parent 840c7789f3
commit c27ba404c0
9 changed files with 1391 additions and 19 deletions

View file

@ -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<Arc<AppState>>,
mut multipart: axum::extract::Multipart,
) -> Result<Json<Document>, (StatusCode, Json<serde_json::Value>)> {
let mut file_bytes: Option<Vec<u8>> = 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!("<p>{}</p>", text.replace('\n', "</p><p>"))
}
_ => {
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<Arc<AppState>>,
Json(req): Json<CompareDocumentsRequest>,

View file

@ -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<Arc<AppState>> {
.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))

View file

@ -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<char> = rtf.chars().collect();
let mut i = 0;
html.push_str("<div>");
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("<strong>");
bold = true;
}
}
"b0" => {
if bold {
html.push_str("</strong>");
bold = false;
}
}
"i" => {
if !italic {
html.push_str("<em>");
italic = true;
}
}
"i0" => {
if italic {
html.push_str("</em>");
italic = false;
}
}
"ul" => {
if !underline {
html.push_str("<u>");
underline = true;
}
}
"ulnone" => {
if underline {
html.push_str("</u>");
underline = false;
}
}
"par" | "line" => html.push_str("<br>"),
"tab" => html.push_str("&nbsp;&nbsp;&nbsp;&nbsp;"),
_ => {}
}
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("</u>");
}
if italic {
html.push_str("</em>");
}
if bold {
html.push_str("</strong>");
}
html.push_str("</div>");
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("<strong>", "\\b ");
result = result.replace("</strong>", "\\b0 ");
result = result.replace("<b>", "\\b ");
result = result.replace("</b>", "\\b0 ");
result = result.replace("<em>", "\\i ");
result = result.replace("</em>", "\\i0 ");
result = result.replace("<i>", "\\i ");
result = result.replace("</i>", "\\i0 ");
result = result.replace("<u>", "\\ul ");
result = result.replace("</u>", "\\ulnone ");
result = result.replace("<br>", "\\par\n");
result = result.replace("<br/>", "\\par\n");
result = result.replace("<br />", "\\par\n");
result = result.replace("<p>", "");
result = result.replace("</p>", "\\par\\par\n");
result = result.replace("<h1>", "\\fs48\\b ");
result = result.replace("</h1>", "\\b0\\fs24\\par\n");
result = result.replace("<h2>", "\\fs36\\b ");
result = result.replace("</h2>", "\\b0\\fs24\\par\n");
result = result.replace("<h3>", "\\fs28\\b ");
result = result.replace("</h3>", "\\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("<div>");
let mut in_text = false;
let mut in_span = false;
let mut current_text = String::new();
let chars: Vec<char> = 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(&current_text);
current_text.clear();
}
html.push_str("<p>");
in_text = true;
} else if tag == "/text:p" {
html.push_str(&current_text);
current_text.clear();
html.push_str("</p>");
in_text = false;
} else if tag.starts_with("text:span") {
if tag.contains("Bold") {
html.push_str("<strong>");
} else if tag.contains("Italic") {
html.push_str("<em>");
}
in_span = true;
} else if tag == "/text:span" {
html.push_str(&current_text);
current_text.clear();
if in_span {
html.push_str("</strong>");
}
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!("<h{level}>"));
in_text = true;
} else if tag.starts_with("/text:h") {
html.push_str(&current_text);
current_text.clear();
html.push_str("</h1>");
in_text = false;
} else if tag == "text:line-break" || tag == "text:line-break/" {
current_text.push_str("<br>");
} else if tag == "text:tab" || tag == "text:tab/" {
current_text.push_str("&nbsp;&nbsp;&nbsp;&nbsp;");
}
} else if in_text {
current_text.push(chars[i]);
}
i += 1;
}
if !current_text.is_empty() {
html.push_str(&current_text);
}
html.push_str("</div>");
html
}
pub fn html_to_odt_content(html: &str) -> String {
let mut odt = String::from(r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
office:version="1.2">
<office:body>
<office:text>
"#);
let mut result = html.to_string();
result = result.replace("<p>", "<text:p>");
result = result.replace("</p>", "</text:p>\n");
result = result.replace("<br>", "<text:line-break/>");
result = result.replace("<br/>", "<text:line-break/>");
result = result.replace("<br />", "<text:line-break/>");
result = result.replace("<strong>", "<text:span text:style-name=\"Bold\">");
result = result.replace("</strong>", "</text:span>");
result = result.replace("<b>", "<text:span text:style-name=\"Bold\">");
result = result.replace("</b>", "</text:span>");
result = result.replace("<em>", "<text:span text:style-name=\"Italic\">");
result = result.replace("</em>", "</text:span>");
result = result.replace("<i>", "<text:span text:style-name=\"Italic\">");
result = result.replace("</i>", "</text:span>");
result = result.replace("<h1>", "<text:h text:outline-level=\"1\">");
result = result.replace("</h1>", "</text:h>\n");
result = result.replace("<h2>", "<text:h text:outline-level=\"2\">");
result = result.replace("</h2>", "</text:h>\n");
result = result.replace("<h3>", "<text:h text:outline-level=\"3\">");
result = result.replace("</h3>", "</text:h>\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!("<text:p>{}</text:p>\n", para.trim()));
}
}
odt.push_str("</office:text>\n</office:body>\n</office:document-content>");
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("<!DOCTYPE html") || text.trim_start().starts_with("<html") {
return "html";
}
if text.trim_start().starts_with('#') || text.contains("\n# ") {
return "markdown";
}
"txt"
}
pub fn convert_to_html(content: &[u8]) -> Result<String, String> {
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!("<p>{}</p>", html_escape(&text).replace('\n', "</p><p>"))),
_ => Err(format!("Unsupported format: {format}")),
}
}

View file

@ -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<String, String> {
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#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>"#);
html.push_str(&sheet.name);
html.push_str(r#"</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #4285f4; color: white; }
tr:nth-child(even) { background-color: #f9f9f9; }
tr:hover { background-color: #f1f1f1; }
.sheet-tabs { margin-bottom: 20px; }
.sheet-tab { padding: 10px 20px; background: #e0e0e0; border: none; cursor: pointer; }
.sheet-tab.active { background: #4285f4; color: white; }
</style>
</head>
<body>
"#);
for (ws_idx, ws) in sheet.worksheets.iter().enumerate() {
html.push_str(&format!("<h2>{}</h2>\n", ws.name));
html.push_str("<table>\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::<u32>(), parts[1].parse::<u32>()) {
max_row = max_row.max(row);
max_col = max_col.max(col);
}
}
}
html.push_str("<thead><tr><th></th>");
for col in 0..=max_col {
let col_letter = column_to_letter(col);
html.push_str(&format!("<th>{col_letter}</th>"));
}
html.push_str("</tr></thead>\n<tbody>\n");
for row in 0..=max_row {
html.push_str(&format!("<tr><td><strong>{}</strong></td>", 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!("<td>{escaped_value}</td>"));
} else {
html.push_str(&format!("<td style=\"{style_str}\">{escaped_value}</td>"));
}
}
html.push_str("</tr>\n");
}
html.push_str("</tbody></table>\n");
}
html.push_str("</body></html>");
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
pub fn export_to_ods(sheet: &Spreadsheet) -> Result<String, String> {
let mut xml = String::from(r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
office:version="1.2">
<office:body>
<office:spreadsheet>
"#);
for ws in &sheet.worksheets {
xml.push_str(&format!("<table:table table:name=\"{}\">\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::<u32>(), parts[1].parse::<u32>()) {
max_row = max_row.max(row);
max_col = max_col.max(col);
}
}
}
for _ in 0..=max_col {
xml.push_str("<table:table-column/>\n");
}
for row in 0..=max_row {
xml.push_str("<table:table-row>\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!(
"<table:table-cell table:formula=\"{}\">\n<text:p>{}</text:p>\n</table:table-cell>\n",
f, value
));
} else if let Ok(num) = value.parse::<f64>() {
xml.push_str(&format!(
"<table:table-cell office:value-type=\"float\" office:value=\"{}\">\n<text:p>{}</text:p>\n</table:table-cell>\n",
num, value
));
} else {
xml.push_str(&format!(
"<table:table-cell office:value-type=\"string\">\n<text:p>{}</text:p>\n</table:table-cell>\n",
value
));
}
}
xml.push_str("</table:table-row>\n");
}
xml.push_str("</table:table>\n");
}
xml.push_str("</office:spreadsheet>\n</office:body>\n</office:document-content>");
Ok(xml)
}
pub fn export_to_pdf_data(sheet: &Spreadsheet) -> Result<Vec<u8>, 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::<u32>(), parts[1].parse::<u32>()) {
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
}

View file

@ -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<Arc<AppState>>,
mut _multipart: axum::extract::Multipart,
State(state): State<Arc<AppState>>,
mut multipart: axum::extract::Multipart,
) -> Result<Json<Spreadsheet>, (StatusCode, Json<serde_json::Value>)> {
Ok(Json(create_new_spreadsheet()))
let mut file_bytes: Option<Vec<u8>> = 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(

View file

@ -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<Vec<Workshee
style,
format: None,
note: None,
locked: None,
has_comment: None,
array_formula_id: None,
},
);
}
@ -854,6 +863,9 @@ pub fn parse_excel_to_worksheets(bytes: &[u8], ext: &str) -> Result<Vec<Workshee
validations: None,
conditional_formats: None,
charts: None,
comments: None,
protection: None,
array_formulas: None,
});
}
@ -866,6 +878,223 @@ pub fn parse_excel_to_worksheets(bytes: &[u8], ext: &str) -> Result<Vec<Workshee
Err("Failed to parse spreadsheet".to_string())
}
pub fn parse_ods_to_worksheets(bytes: &[u8]) -> Result<Vec<Worksheet>, 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<String, CellData> = 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<char> = 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<Spreadsheet, String> {
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,
}
}

View file

@ -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<Arc<AppState>>,
mut multipart: axum::extract::Multipart,
) -> Result<Json<Presentation>, (StatusCode, Json<serde_json::Value>)> {
let mut file_bytes: Option<Vec<u8>> = 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<Presentation, _> = 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))
}

View file

@ -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<Arc<AppState>> {
.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))

View file

@ -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#"<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}" viewBox="0 0 {} {}">
"#,
width, height, width, height
);
let bg_color = slide.background.color.as_deref().unwrap_or("#ffffff");
svg.push_str(&format!(
r#" <rect width="100%" height="100%" fill="{}"/>
"#,
bg_color
));
for element in &slide.elements {
match element.element_type.as_str() {
"text" => {
let text = element.content.text.as_deref().unwrap_or("");
let font_size = element.style.font_size.unwrap_or(18.0);
let color = element.style.color.as_deref().unwrap_or("#000000");
let font_family = element.style.font_family.as_deref().unwrap_or("Arial");
let font_weight = element.style.font_weight.as_deref().unwrap_or("normal");
svg.push_str(&format!(
r#" <text x="{}" y="{}" font-family="{}" font-size="{}" font-weight="{}" fill="{}">{}</text>
"#,
element.x,
element.y + font_size,
font_family,
font_size,
font_weight,
color,
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");
let stroke = element.style.stroke.as_deref().unwrap_or("none");
let stroke_width = element.style.stroke_width.unwrap_or(1.0);
match shape_type {
"rectangle" | "rect" => {
let rx = element.style.border_radius.unwrap_or(0.0);
svg.push_str(&format!(
r#" <rect x="{}" y="{}" width="{}" height="{}" rx="{}" fill="{}" stroke="{}" stroke-width="{}"/>
"#,
element.x, element.y, element.width, element.height, rx, fill, stroke, stroke_width
));
}
"circle" | "ellipse" => {
let cx = element.x + element.width / 2.0;
let cy = element.y + element.height / 2.0;
let rx = element.width / 2.0;
let ry = element.height / 2.0;
svg.push_str(&format!(
r#" <ellipse cx="{}" cy="{}" rx="{}" ry="{}" fill="{}" stroke="{}" stroke-width="{}"/>
"#,
cx, cy, rx, ry, fill, stroke, stroke_width
));
}
"line" => {
svg.push_str(&format!(
r#" <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}" stroke-width="{}"/>
"#,
element.x,
element.y,
element.x + element.width,
element.y + element.height,
stroke,
stroke_width
));
}
"triangle" => {
let x1 = element.x + element.width / 2.0;
let y1 = element.y;
let x2 = element.x;
let y2 = element.y + element.height;
let x3 = element.x + element.width;
let y3 = element.y + element.height;
svg.push_str(&format!(
r#" <polygon points="{},{} {},{} {},{}" fill="{}" stroke="{}" stroke-width="{}"/>
"#,
x1, y1, x2, y2, x3, y3, fill, stroke, stroke_width
));
}
_ => {}
}
}
"image" => {
if let Some(ref src) = element.content.src {
svg.push_str(&format!(
r#" <image x="{}" y="{}" width="{}" height="{}" href="{}"/>
"#,
element.x, element.y, element.width, element.height, src
));
}
}
_ => {}
}
}
svg.push_str("</svg>");
svg
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
pub fn export_slide_to_png_placeholder(slide: &Slide, width: u32, height: u32) -> Vec<u8> {
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#"<?xml version="1.0" encoding="UTF-8"?>
<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
xmlns:presentation="urn:oasis:names:tc:opendocument:xmlns:presentation:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
office:version="1.2">
<office:body>
<office:presentation>
"#);
for (idx, slide) in presentation.slides.iter().enumerate() {
xml.push_str(&format!(
"<draw:page draw:name=\"Slide{}\" draw:style-name=\"dp1\">\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#" <draw:frame svg:x="{}pt" svg:y="{}pt" svg:width="{}pt" svg:height="{}pt">
<draw:text-box>
<text:p>{}</text:p>
</draw:text-box>
</draw:frame>
"#,
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#" <draw:rect svg:x="{}pt" svg:y="{}pt" svg:width="{}pt" svg:height="{}pt" draw:fill-color="{}"/>
"#,
element.x, element.y, element.width, element.height, fill
));
}
"circle" | "ellipse" => {
xml.push_str(&format!(
r#" <draw:ellipse svg:x="{}pt" svg:y="{}pt" svg:width="{}pt" svg:height="{}pt" draw:fill-color="{}"/>
"#,
element.x, element.y, element.width, element.height, fill
));
}
_ => {}
}
}
"image" => {
if let Some(ref src) = element.content.src {
xml.push_str(&format!(
r#" <draw:frame svg:x="{}pt" svg:y="{}pt" svg:width="{}pt" svg:height="{}pt">
<draw:image xlink:href="{}"/>
</draw:frame>
"#,
element.x, element.y, element.width, element.height, src
));
}
}
_ => {}
}
}
xml.push_str("</draw:page>\n");
}
xml.push_str("</office:presentation>\n</office:body>\n</office:document-content>");
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!("![Image]({})\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<Slide> {
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!("<p>{}</p>", 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,
}
}