Skip to content

Commit e7a360c

Browse files
committed
fixed word export
1 parent 2c86443 commit e7a360c

File tree

6 files changed

+283
-77
lines changed

6 files changed

+283
-77
lines changed

src-tauri/src/kmd.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,7 @@ fn get_reference_text(label: &str, registry: &CrossRefRegistry) -> String {
517517
/// - Replaces @tbl:label with "Table N"
518518
/// - Removes {#sec:label} from headings
519519
/// - Removes {#tbl:label} from after tables
520+
/// - Converts ![caption](url){#fig:label} to standard ![caption](url)
520521
fn preprocess_markdown_for_docx(markdown: &str, registry: &CrossRefRegistry) -> String {
521522
let mut result = markdown.to_string();
522523

@@ -529,6 +530,11 @@ fn preprocess_markdown_for_docx(markdown: &str, registry: &CrossRefRegistry) ->
529530
})
530531
.to_string();
531532

533+
// Convert figure syntax: ![caption](url){#fig:label} -> ![caption](url)
534+
// This allows pandoc to properly embed the image
535+
let fig_re = Regex::new(r"!\[([^\]]*)\]\(([^)]+)\)\{#fig:[^}]+\}").unwrap();
536+
result = fig_re.replace_all(&result, "![$1]($2)").to_string();
537+
532538
// Remove {#sec:label} from headings (keep the heading text)
533539
let sec_label_re = Regex::new(r"(\s*)\{#sec:[^}]+\}").unwrap();
534540
result = sec_label_re.replace_all(&result, "").to_string();
@@ -934,9 +940,85 @@ fn markdown_to_docx(markdown: &str) -> Result<Docx, String> {
934940
Ok(docx)
935941
}
936942

943+
/// Check if pandoc is available on the system
944+
fn is_pandoc_available() -> bool {
945+
use std::process::Command;
946+
Command::new("pandoc")
947+
.arg("--version")
948+
.output()
949+
.map(|o| o.status.success())
950+
.unwrap_or(false)
951+
}
952+
953+
/// Export markdown to DOCX using pandoc
954+
fn export_with_pandoc(path: &str, content: &str) -> Result<(), String> {
955+
use std::process::{Command, Stdio};
956+
use std::io::Write;
957+
958+
// Preprocess the markdown to convert custom syntax to standard markdown
959+
let crossref_registry = build_crossref_registry(content);
960+
let mut processed_content = preprocess_markdown_for_docx(content, &crossref_registry);
961+
962+
// Convert Tauri asset:// URLs back to absolute paths for pandoc
963+
// asset://localhost/%2Fpath%2Fto%2Ffile -> /path/to/file
964+
let asset_url_re = Regex::new(r"asset://localhost/(%[0-9A-Fa-f]{2}[^)\s]*)").unwrap();
965+
processed_content = asset_url_re.replace_all(&processed_content, |caps: &regex::Captures| {
966+
let encoded_path = caps.get(1).map(|m| m.as_str()).unwrap_or("");
967+
// Simple percent-decoding
968+
let mut decoded = String::new();
969+
let mut chars = encoded_path.chars().peekable();
970+
while let Some(c) = chars.next() {
971+
if c == '%' {
972+
let hex: String = chars.by_ref().take(2).collect();
973+
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
974+
decoded.push(byte as char);
975+
} else {
976+
decoded.push('%');
977+
decoded.push_str(&hex);
978+
}
979+
} else {
980+
decoded.push(c);
981+
}
982+
}
983+
decoded
984+
}).to_string();
985+
986+
let mut child = Command::new("pandoc")
987+
.arg("-f")
988+
.arg("markdown")
989+
.arg("-t")
990+
.arg("docx")
991+
.arg("-o")
992+
.arg(path)
993+
.stdin(Stdio::piped())
994+
.spawn()
995+
.map_err(|e| format!("Failed to start pandoc: {}", e))?;
996+
997+
if let Some(mut stdin) = child.stdin.take() {
998+
stdin.write_all(processed_content.as_bytes())
999+
.map_err(|e| format!("Failed to write to pandoc stdin: {}", e))?;
1000+
}
1001+
1002+
let status = child.wait()
1003+
.map_err(|e| format!("Failed to wait for pandoc: {}", e))?;
1004+
1005+
if !status.success() {
1006+
return Err("Pandoc conversion failed".to_string());
1007+
}
1008+
1009+
Ok(())
1010+
}
1011+
9371012
/// Export markdown content as a DOCX file
1013+
/// Uses pandoc if available for better quality output, falls back to docx_rs library
9381014
#[tauri::command]
9391015
pub fn export_docx(path: String, content: String) -> Result<(), String> {
1016+
// Try pandoc first for better quality output
1017+
if is_pandoc_available() {
1018+
return export_with_pandoc(&path, &content);
1019+
}
1020+
1021+
// Fallback to Rust docx_rs library
9401022
let docx = markdown_to_docx(&content)?;
9411023

9421024
let file = File::create(&path).map_err(|e| format!("Failed to create file: {}", e))?;

src/components/formatting-toolbar.js

Lines changed: 1 addition & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ export function initFormattingToolbar(editor) {
101101

102102
// Listen for insert requests from context menu
103103
window.addEventListener('insert-table-request', () => insertTableCommand());
104-
window.addEventListener('insert-image-request', () => insertImageCommand());
104+
window.addEventListener('insert-image-request', () => insertFigureCommand());
105105
}
106106

107107
/**
@@ -264,39 +264,6 @@ function insertLinkCommand() {
264264
});
265265
}
266266

267-
/**
268-
* Insert image - prompts for URL and alt text
269-
*/
270-
function insertImageCommand() {
271-
if (!editorInstance) return;
272-
273-
editorInstance.action((ctx) => {
274-
const view = ctx.get(editorViewCtx);
275-
const { state, dispatch } = view;
276-
277-
const url = prompt("Enter image URL:");
278-
if (!url) {
279-
view.focus();
280-
return;
281-
}
282-
283-
const alt = prompt("Enter alt text (optional):", "") || "";
284-
285-
const imageType = state.schema.nodes.image;
286-
if (!imageType) {
287-
console.warn("Image node type not found in schema");
288-
view.focus();
289-
return;
290-
}
291-
292-
const { from } = state.selection;
293-
const imageNode = imageType.create({ src: url, alt: alt });
294-
const tr = state.tr.insert(from, imageNode);
295-
dispatch(tr);
296-
view.focus();
297-
});
298-
}
299-
300267
/**
301268
* Insert a table - shows dialog for rows/columns/caption/label
302269
*/
@@ -828,39 +795,6 @@ function showCrossRefDialog(refs, callback) {
828795
});
829796
}
830797

831-
/**
832-
* Insert a table label
833-
*/
834-
function insertTableLabelCommand() {
835-
if (!editorInstance) return;
836-
837-
const nextNum = tableRegistry.size + 1;
838-
const suggestedLabel = `tbl:table${nextNum}`;
839-
840-
const label = prompt(`Enter table label (for cross-references):\n\nSuggested: ${suggestedLabel}\n\nUsage: Place {#tbl:label} on a new line after your table`, suggestedLabel);
841-
842-
if (!label) return;
843-
844-
let finalLabel = label.trim();
845-
if (!finalLabel.startsWith('tbl:')) {
846-
finalLabel = 'tbl:' + finalLabel;
847-
}
848-
849-
editorInstance.action((ctx) => {
850-
const view = ctx.get(editorViewCtx);
851-
const { state, dispatch } = view;
852-
const { from } = state.selection;
853-
854-
// Insert the table label syntax
855-
const labelText = `\n\n{#${finalLabel}}`;
856-
const tr = state.tr.insertText(labelText, from);
857-
dispatch(tr);
858-
view.focus();
859-
860-
// Register the table
861-
registerFigure(finalLabel);
862-
});
863-
}
864798

865799
/**
866800
* Insert hard break (line break within paragraph)

src/editor.js

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,7 @@ export function getMarkdown() {
5555
editor.action((ctx) => {
5656
const view = ctx.get(editorViewCtx);
5757
const serializer = ctx.get(serializerCtx);
58-
console.log("[DEBUG] getMarkdown - doc:", JSON.stringify(view.state.doc.toJSON(), null, 2));
5958
markdown = serializer(view.state.doc);
60-
console.log("[DEBUG] getMarkdown - result:", markdown);
6159
});
6260
} catch (err) {
6361
console.error("[ERROR] getMarkdown serialization failed:", err);

src/kmd-service.js

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export async function exportDocument() {
1414
filters: [{ name: 'Korppi Document', extensions: ['kmd'] }],
1515
defaultPath: 'document.kmd'
1616
});
17-
17+
1818
if (path) {
1919
const meta = await invoke("export_kmd", { path });
2020
return { path, meta };
@@ -32,7 +32,7 @@ export async function importDocument() {
3232
filters: [{ name: 'Korppi Document', extensions: ['kmd'] }],
3333
multiple: false
3434
});
35-
35+
3636
if (path) {
3737
const meta = await invoke("import_kmd", { path });
3838
return meta;
@@ -51,7 +51,7 @@ export async function exportAsMarkdown(markdownContent) {
5151
filters: [{ name: 'Markdown', extensions: ['md'] }],
5252
defaultPath: 'document.md'
5353
});
54-
54+
5555
if (path) {
5656
await invoke("export_markdown", { path, content: markdownContent });
5757
return path;
@@ -62,22 +62,104 @@ export async function exportAsMarkdown(markdownContent) {
6262
/**
6363
* Export the document as a DOCX file.
6464
* Gets the current editor content and converts it to DOCX format.
65+
* Requires pandoc for proper conversion - shows warning if not available.
6566
* @param {string} markdownContent - The markdown content to export
6667
* @returns {Promise<string|null>} Export path or null if cancelled
6768
*/
6869
export async function exportAsDocx(markdownContent) {
70+
// Check if pandoc is available
71+
const hasPandoc = await invoke("check_pandoc_available");
72+
73+
if (!hasPandoc) {
74+
// Show warning dialog about pandoc requirement
75+
const shouldContinue = await showPandocWarningDialog();
76+
if (!shouldContinue) {
77+
return null;
78+
}
79+
}
80+
6981
const path = await save({
7082
filters: [{ name: 'Word Document', extensions: ['docx'] }],
7183
defaultPath: 'document.docx'
7284
});
73-
85+
7486
if (path) {
7587
await invoke("export_docx", { path, content: markdownContent });
7688
return path;
7789
}
7890
return null;
7991
}
8092

93+
/**
94+
* Show a warning dialog when pandoc is not installed.
95+
* Provides link to download pandoc.
96+
* @returns {Promise<boolean>} true if user wants to continue without pandoc, false to cancel
97+
*/
98+
async function showPandocWarningDialog() {
99+
return new Promise((resolve) => {
100+
const overlay = document.createElement('div');
101+
overlay.className = 'modal';
102+
overlay.style.display = 'flex';
103+
overlay.innerHTML = `
104+
<div class="modal-content" style="max-width: 450px;">
105+
<div class="modal-header">
106+
<h2>⚠️ Pandoc Not Found</h2>
107+
</div>
108+
<div class="modal-body">
109+
<p><strong>Pandoc</strong> is required for exporting documents with proper formatting.</p>
110+
<p>Without Pandoc, the export will contain only plain text without formatting, images, or tables.</p>
111+
<p style="margin-top: 12px;">
112+
<a href="#" id="pandoc-download-link" style="color: var(--accent-color); text-decoration: underline;">
113+
📥 Download Pandoc from pandoc.org
114+
</a>
115+
</p>
116+
</div>
117+
<div class="modal-footer">
118+
<button id="pandoc-cancel" class="btn-secondary">Cancel Export</button>
119+
<button id="pandoc-continue" class="btn-primary">Export Anyway</button>
120+
</div>
121+
</div>
122+
`;
123+
document.body.appendChild(overlay);
124+
125+
const downloadLink = overlay.querySelector('#pandoc-download-link');
126+
const cancelBtn = overlay.querySelector('#pandoc-cancel');
127+
const continueBtn = overlay.querySelector('#pandoc-continue');
128+
129+
const cleanup = () => document.body.removeChild(overlay);
130+
131+
downloadLink.addEventListener('click', async (e) => {
132+
e.preventDefault();
133+
// Open the pandoc installation page in the default browser
134+
await invoke("open_url", { url: "https://pandoc.org/installing.html" });
135+
});
136+
137+
continueBtn.addEventListener('click', () => {
138+
cleanup();
139+
resolve(true);
140+
});
141+
142+
cancelBtn.addEventListener('click', () => {
143+
cleanup();
144+
resolve(false);
145+
});
146+
147+
overlay.addEventListener('click', (e) => {
148+
if (e.target === overlay) {
149+
cleanup();
150+
resolve(false);
151+
}
152+
});
153+
154+
overlay.addEventListener('keydown', (e) => {
155+
if (e.key === 'Escape') {
156+
cleanup();
157+
resolve(false);
158+
}
159+
});
160+
});
161+
}
162+
81163
/**
82164
* Get current document metadata.
83165
* @returns {Promise<Object>} Document metadata

0 commit comments

Comments
 (0)