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,
|
DocsSaveRequest, DocsSaveResponse, DocsAiRequest, DocsAiResponse, Document, DocumentMetadata,
|
||||||
SearchQuery, TemplateResponse,
|
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::{
|
use crate::docs::types::{
|
||||||
AcceptRejectAllRequest, AcceptRejectChangeRequest, AddCommentRequest, AddEndnoteRequest,
|
AcceptRejectAllRequest, AcceptRejectChangeRequest, AddCommentRequest, AddEndnoteRequest,
|
||||||
AddFootnoteRequest, ApplyStyleRequest, CompareDocumentsRequest, CompareDocumentsResponse,
|
AddFootnoteRequest, ApplyStyleRequest, CompareDocumentsRequest, CompareDocumentsResponse,
|
||||||
|
|
@ -1575,6 +1575,65 @@ pub async fn handle_get_outline(
|
||||||
Ok(Json(OutlineResponse { items }))
|
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(
|
pub async fn handle_compare_documents(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(req): Json<CompareDocumentsRequest>,
|
Json(req): Json<CompareDocumentsRequest>,
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,12 @@ pub use handlers::{
|
||||||
handle_delete_endnote, handle_delete_footnote, handle_delete_style, handle_docs_ai,
|
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_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_export_html, handle_export_md, handle_export_pdf, handle_export_txt,
|
||||||
handle_generate_toc, handle_get_document, handle_get_outline, handle_list_comments,
|
handle_generate_toc, handle_get_document, handle_get_outline, handle_import_document,
|
||||||
handle_list_documents, handle_list_endnotes, handle_list_footnotes, handle_list_styles,
|
handle_list_comments, handle_list_documents, handle_list_endnotes, handle_list_footnotes,
|
||||||
handle_list_track_changes, handle_new_document, handle_reply_comment, handle_resolve_comment,
|
handle_list_styles, handle_list_track_changes, handle_new_document, handle_reply_comment,
|
||||||
handle_save_document, handle_search_documents, handle_template_blank, handle_template_letter,
|
handle_resolve_comment, handle_save_document, handle_search_documents, handle_template_blank,
|
||||||
handle_template_meeting, handle_template_report, handle_update_endnote, handle_update_footnote,
|
handle_template_letter, handle_template_meeting, handle_template_report, handle_update_endnote,
|
||||||
handle_update_style, handle_update_toc,
|
handle_update_footnote, handle_update_style, handle_update_toc,
|
||||||
};
|
};
|
||||||
pub use types::{
|
pub use types::{
|
||||||
AiRequest, AiResponse, Collaborator, CollabMessage, CommentReply, ComparisonSummary, Document,
|
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/md", get(handle_export_md))
|
||||||
.route("/api/docs/export/html", get(handle_export_html))
|
.route("/api/docs/export/html", get(handle_export_html))
|
||||||
.route("/api/docs/export/txt", get(handle_export_txt))
|
.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", post(handle_add_comment))
|
||||||
.route("/api/docs/comment/reply", post(handle_reply_comment))
|
.route("/api/docs/comment/reply", post(handle_reply_comment))
|
||||||
.route("/api/docs/comment/resolve", post(handle_resolve_comment))
|
.route("/api/docs/comment/resolve", post(handle_resolve_comment))
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
use chrono::{DateTime, Duration, Utc};
|
use chrono::{DateTime, Duration, Utc};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
pub fn format_document_list_item(
|
pub fn format_document_list_item(
|
||||||
id: &str,
|
id: &str,
|
||||||
|
|
@ -269,3 +270,304 @@ pub fn generate_document_id() -> String {
|
||||||
pub fn get_user_docs_path(user_id: &str) -> String {
|
pub fn get_user_docs_path(user_id: &str) -> String {
|
||||||
format!("users/{}/docs", user_id)
|
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 base64::Engine;
|
||||||
use crate::sheet::types::{CellStyle, Spreadsheet};
|
use crate::sheet::types::{CellStyle, Spreadsheet};
|
||||||
use rust_xlsxwriter::{Color, Format, FormatAlign, Workbook};
|
use rust_xlsxwriter::{Color, Format, FormatAlign, Workbook};
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
pub fn export_to_xlsx(sheet: &Spreadsheet) -> Result<String, String> {
|
pub fn export_to_xlsx(sheet: &Spreadsheet) -> Result<String, String> {
|
||||||
let mut workbook = Workbook::new();
|
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 {
|
pub fn export_to_json(sheet: &Spreadsheet) -> String {
|
||||||
serde_json::to_string_pretty(sheet).unwrap_or_default()
|
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::shared::state::AppState;
|
||||||
use crate::sheet::collaboration::broadcast_sheet_change;
|
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::formulas::evaluate_formula;
|
||||||
use crate::sheet::storage::{
|
use crate::sheet::storage::{
|
||||||
create_new_spreadsheet, delete_sheet_from_drive, get_current_user_id, list_sheets_from_drive,
|
create_new_spreadsheet, delete_sheet_from_drive, get_current_user_id, import_spreadsheet_bytes,
|
||||||
load_sheet_by_id, load_sheet_from_drive, parse_csv_to_worksheets, parse_excel_to_worksheets,
|
list_sheets_from_drive, load_sheet_by_id, load_sheet_from_drive, parse_csv_to_worksheets,
|
||||||
save_sheet_to_drive,
|
parse_excel_to_worksheets, save_sheet_to_drive,
|
||||||
};
|
};
|
||||||
use crate::sheet::types::{
|
use crate::sheet::types::{
|
||||||
AddCommentRequest, AddExternalLinkRequest, AddNoteRequest, ArrayFormula, ArrayFormulaRequest,
|
AddCommentRequest, AddExternalLinkRequest, AddNoteRequest, ArrayFormula, ArrayFormulaRequest,
|
||||||
|
|
@ -472,6 +472,29 @@ pub async fn handle_export_sheet(
|
||||||
let json = export_to_json(&sheet);
|
let json = export_to_json(&sheet);
|
||||||
Ok(([(axum::http::header::CONTENT_TYPE, "application/json")], json))
|
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((
|
_ => Err((
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
Json(serde_json::json!({ "error": "Unsupported format" })),
|
Json(serde_json::json!({ "error": "Unsupported format" })),
|
||||||
|
|
@ -1158,10 +1181,46 @@ pub async fn handle_add_note(
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn handle_import_sheet(
|
pub async fn handle_import_sheet(
|
||||||
State(_state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
mut _multipart: axum::extract::Multipart,
|
mut multipart: axum::extract::Multipart,
|
||||||
) -> Result<Json<Spreadsheet>, (StatusCode, Json<serde_json::Value>)> {
|
) -> 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(
|
pub async fn handle_add_comment(
|
||||||
|
|
|
||||||
|
|
@ -778,6 +778,9 @@ pub fn parse_csv_to_worksheets(
|
||||||
style: None,
|
style: None,
|
||||||
format: None,
|
format: None,
|
||||||
note: None,
|
note: None,
|
||||||
|
locked: None,
|
||||||
|
has_comment: None,
|
||||||
|
array_formula_id: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -797,6 +800,9 @@ pub fn parse_csv_to_worksheets(
|
||||||
validations: None,
|
validations: None,
|
||||||
conditional_formats: None,
|
conditional_formats: None,
|
||||||
charts: 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,
|
style,
|
||||||
format: None,
|
format: None,
|
||||||
note: 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,
|
validations: None,
|
||||||
conditional_formats: None,
|
conditional_formats: None,
|
||||||
charts: 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())
|
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 {
|
pub fn create_new_spreadsheet() -> Spreadsheet {
|
||||||
Spreadsheet {
|
Spreadsheet {
|
||||||
id: Uuid::new_v4().to_string(),
|
id: Uuid::new_v4().to_string(),
|
||||||
|
|
@ -884,8 +1113,13 @@ pub fn create_new_spreadsheet() -> Spreadsheet {
|
||||||
validations: None,
|
validations: None,
|
||||||
conditional_formats: None,
|
conditional_formats: None,
|
||||||
charts: None,
|
charts: None,
|
||||||
|
comments: None,
|
||||||
|
protection: None,
|
||||||
|
array_formulas: None,
|
||||||
}],
|
}],
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
updated_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,
|
get_current_user_id, list_presentations_from_drive, load_presentation_by_id,
|
||||||
load_presentation_from_drive, save_presentation_to_drive,
|
load_presentation_from_drive, save_presentation_to_drive,
|
||||||
};
|
};
|
||||||
|
use crate::slides::utils::slides_from_markdown;
|
||||||
use crate::slides::types::{
|
use crate::slides::types::{
|
||||||
AddElementRequest, AddMediaRequest, AddSlideRequest, ApplyThemeRequest,
|
AddElementRequest, AddMediaRequest, AddSlideRequest, ApplyThemeRequest,
|
||||||
ApplyTransitionToAllRequest, CollaborationCursor, CollaborationSelection, DeleteElementRequest,
|
ApplyTransitionToAllRequest, CollaborationCursor, CollaborationSelection, DeleteElementRequest,
|
||||||
|
|
@ -16,7 +17,7 @@ use crate::slides::types::{
|
||||||
SlidesAiResponse, StartPresenterRequest, UpdateCursorRequest, UpdateElementRequest,
|
SlidesAiResponse, StartPresenterRequest, UpdateCursorRequest, UpdateElementRequest,
|
||||||
UpdateMediaRequest, UpdatePresenterRequest, UpdateSelectionRequest, UpdateSlideNotesRequest,
|
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::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
|
|
@ -611,9 +612,35 @@ pub async fn handle_export_presentation(
|
||||||
Ok(([(axum::http::header::CONTENT_TYPE, "text/html")], html))
|
Ok(([(axum::http::header::CONTENT_TYPE, "text/html")], html))
|
||||||
}
|
}
|
||||||
"json" => {
|
"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))
|
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" => {
|
"pptx" => {
|
||||||
Ok((
|
Ok((
|
||||||
[(
|
[(
|
||||||
|
|
@ -1107,3 +1134,79 @@ pub async fn handle_get_presenter_notes(
|
||||||
next_slide_thumbnail: None,
|
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_apply_transition_to_all, handle_delete_element, handle_delete_media,
|
||||||
handle_delete_presentation, handle_delete_slide, handle_duplicate_slide,
|
handle_delete_presentation, handle_delete_slide, handle_duplicate_slide,
|
||||||
handle_end_presenter, handle_export_presentation, handle_get_presentation_by_id,
|
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_list_presentations, handle_list_selections, handle_load_presentation,
|
||||||
handle_new_presentation, handle_remove_transition, handle_reorder_slides,
|
handle_new_presentation, handle_remove_transition, handle_reorder_slides,
|
||||||
handle_save_presentation, handle_search_presentations, handle_set_transition,
|
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/element/delete", post(handle_delete_element))
|
||||||
.route("/api/slides/theme", post(handle_apply_theme))
|
.route("/api/slides/theme", post(handle_apply_theme))
|
||||||
.route("/api/slides/export", post(handle_export_presentation))
|
.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/cursor", post(handle_update_cursor))
|
||||||
.route("/api/slides/selection", post(handle_update_selection))
|
.route("/api/slides/selection", post(handle_update_selection))
|
||||||
.route("/api/slides/cursors", get(handle_list_cursors))
|
.route("/api/slides/cursors", get(handle_list_cursors))
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
use crate::slides::types::{
|
use crate::slides::types::{
|
||||||
ElementContent, ElementStyle, PresentationTheme, Slide, SlideBackground, SlideElement,
|
ElementContent, ElementStyle, Presentation, PresentationTheme, Slide, SlideBackground,
|
||||||
ThemeColors, ThemeFonts,
|
SlideElement, ThemeColors, ThemeFonts,
|
||||||
};
|
};
|
||||||
|
use base64::Engine;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub fn create_default_theme() -> PresentationTheme {
|
pub fn create_default_theme() -> PresentationTheme {
|
||||||
|
|
@ -312,3 +313,387 @@ pub fn sanitize_filename(name: &str) -> String {
|
||||||
.trim_matches('_')
|
.trim_matches('_')
|
||||||
.to_string()
|
.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