From d78181fdd9b9eda2ec4e15a18c033393c71a6fd9 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sat, 15 Aug 2026 22:40:32 +0200 Subject: [PATCH] fix: Save round-trips a .docx-opened document as .docx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl+S always built .odt bytes and wrote them into whatever handle was open — including a handle obtained by opening a .docx, silently overwriting it with ODF content under its old name. Track the loaded format and route Save through the matching exporter and handle; the title's extension label now reflects it instead of a hardcoded .odt. Save As stays .odt-only, so it switches the format back. --- src/App.svelte | 20 ++++++++++++++++++-- src/lib/components/ribbon/Ribbon.svelte | 4 +++- src/lib/export/saveFile.ts | 24 +++++++++++++++++------- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index 0e9292c..c508712 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -10,7 +10,7 @@ import FindReplaceBar from './lib/components/FindReplaceBar.svelte'; import { buildOdt, deriveFilename } from './lib/export/odt'; import { exportPdf, printPdf, printRaster } from './lib/export/pdf'; - import { supportsFsAccess, saveOdt, saveAsOdt, saveAsDocx, saveAsTemplate, openOdt } from './lib/export/saveFile'; + import { supportsFsAccess, saveOdt, saveAsOdt, saveDocx, saveAsDocx, saveAsTemplate, openOdt } from './lib/export/saveFile'; import { loadRecentFiles, rememberRecentFile, readRecentFile, forgetRecentFiles, type RecentFile } from './lib/storage/recentFiles'; import { importOdt } from './lib/import/odt'; import { importDocx } from './lib/import/docx'; @@ -173,6 +173,9 @@ // The document name (without .odt). Source of truth for the save filename; // set on open, editable in the header, blank → heading-derived fallback. let documentName: string = $state(loadDocName()); + // The loaded file's format; drives the title's extension label and which + // exporter "Save" round-trips through (odt-opened saves odt, docx-opened saves docx). + let documentFormat: 'odt' | 'docx' = $state('odt'); let docProps: DocProperties = $state(loadDocProperties()); let docPropsOpen = $state(false); let hyphenate = $state(loadHyphenation()); @@ -560,6 +563,7 @@ docProps = { ...EMPTY_DOC_PROPERTIES }; saveDocProperties(docProps); fileHandle = null; + documentFormat = 'odt'; // Styles and note settings live in the document, so a new one starts from the built-ins setStyleSheet(builtinStyleSheet()); setNoteSettings(DEFAULT_NOTE_SETTINGS); @@ -650,6 +654,7 @@ footer: result.footerDistanceCm ?? DEFAULT_HF_DISTANCES.footer, }; fileHandle = isTemplate ? null : handle; + documentFormat = isDocx ? 'docx' : 'odt'; if (sourceName) recentFiles = await rememberRecentFile(sourceName, isTemplate ? null : handle); // Warn about fonts the document uses but the browser can't render, so text @@ -743,6 +748,15 @@ exportMenuOpen = false; const json = editor.getJSON() as Parameters[0]; try { + // A document opened as .docx round-trips through the same format, like both + // reference word processors — not silently rewritten to .odt under its old name. + if (documentFormat === 'docx') { + const { buildDocx } = await import('./lib/export/docx'); + const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges()); + fileHandle = await saveDocx(bytes, suggestedFilenameDocx(json), fileHandle); + recentFiles = await rememberRecentFile(fileHandle?.name ?? suggestedFilenameDocx(json), fileHandle); + return; + } const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges()); fileHandle = await saveOdt(bytes, suggestedFilename(json), fileHandle); recentFiles = await rememberRecentFile(fileHandle?.name ?? suggestedFilename(json), fileHandle); @@ -762,6 +776,7 @@ try { const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges()); fileHandle = await saveAsOdt(bytes, suggestedFilename(json)); + documentFormat = 'odt'; // Save As is odt-only, so a docx-opened document switches format here. recentFiles = await rememberRecentFile(fileHandle?.name ?? suggestedFilename(json), fileHandle); } catch (err) { if ((err as DOMException)?.name === 'AbortError') return; @@ -1043,6 +1058,7 @@ tick={activeTick} bind:chromeMode bind:documentName + {documentFormat} bind:showFormattingMarks bind:showRuler bind:splitView @@ -1134,7 +1150,7 @@ onkeydown={(e) => e.key === 'Enter' && (e.currentTarget as HTMLInputElement).blur()} onblur={() => (documentName = documentName.trim())} /> - .odt + .{documentFormat} diff --git a/src/lib/components/ribbon/Ribbon.svelte b/src/lib/components/ribbon/Ribbon.svelte index 3d32662..f2a8b1c 100644 --- a/src/lib/components/ribbon/Ribbon.svelte +++ b/src/lib/components/ribbon/Ribbon.svelte @@ -36,6 +36,7 @@ tick, chromeMode = $bindable('ribbon'), documentName = $bindable(''), + documentFormat = 'odt', showFormattingMarks = $bindable(false), showRuler = $bindable(true), splitView = $bindable(false), @@ -73,6 +74,7 @@ tick: number; chromeMode?: ChromeMode; documentName?: string; + documentFormat?: 'odt' | 'docx'; showFormattingMarks?: boolean; showRuler?: boolean; splitView?: boolean; @@ -278,7 +280,7 @@ onkeydown={(e) => e.key === 'Enter' && (e.currentTarget as HTMLInputElement).blur()} onblur={() => (documentName = documentName.trim())} /> - .odt + .{documentFormat}