feat(office): Add Phase 4 import/export - HTML, ODS, Markdown, RTF, SVG, ODP formats
This commit is contained in:
parent
840c7789f3
commit
c27ba404c0
9 changed files with 1391 additions and 19 deletions
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(" "),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
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(¤t_text);
|
||||
current_text.clear();
|
||||
}
|
||||
html.push_str("<p>");
|
||||
in_text = true;
|
||||
} else if tag == "/text:p" {
|
||||
html.push_str(¤t_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(¤t_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(¤t_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(" ");
|
||||
}
|
||||
} else if in_text {
|
||||
current_text.push(chars[i]);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if !current_text.is_empty() {
|
||||
html.push_str(¤t_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}")),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
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!("\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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue