diff --git a/src/App.svelte b/src/App.svelte
index 378a3cf..4b19bfe 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -10,7 +10,7 @@
import FindReplaceBar from './lib/components/FindReplaceBar.svelte';
import type { TiptapNode } from 'odf-kit';
import { exportPdf, printPdf, printRaster } from './lib/export/pdf';
- import { supportsFsAccess, saveToHandle, saveAsDocument, saveAsDocx, saveAsTemplate, openOdt } from './lib/export/saveFile';
+ import { supportsFsAccess, saveDocument, openOdt } from './lib/export/saveFile';
import { loadRecentFiles, rememberRecentFile, readRecentFile, forgetRecentFile, forgetRecentFiles, pruneRecentFiles, type RecentFile } from './lib/storage/recentFiles';
import { isProtected, decryptPackage, WRONG_PASSWORD } from './lib/crypto/protect';
import { convertUnsupportedImages } from './lib/import/imageFormats';
@@ -568,7 +568,6 @@
if (fsSupported) void pruneRecentFiles().then((list) => (recentFiles = list));
let fileInput: HTMLInputElement | null = $state(null);
let pdfBusy = $state(false);
- let docxBusy = $state(false);
let exportMenuOpen = $state(false);
// The editable zones as one section — section 1 of the export.
@@ -979,42 +978,40 @@
async function handleSave() {
if (!editor) return;
- // The first save is Save As: the location, and with it the format, is still open.
- const handle = fileHandle;
- if (!handle) return handleSaveAs();
exportMenuOpen = false;
if (!(await ensurePassword())) return;
+ const json = editor.getJSON() as TiptapNode;
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.
- await saveToHandle(await buildBytes(documentFormat, editor.getJSON() as TiptapNode), handle, docPassword);
- recentFiles = await rememberRecentFile(handle.name, handle);
+ const name = documentFormat === 'docx' ? suggestedFilenameDocx(json) : suggestedFilename(json);
+ fileHandle = await saveDocument(await buildBytes(documentFormat, json), name, documentFormat, fileHandle, docPassword);
+ recentFiles = await rememberRecentFile(fileHandle?.name ?? name, fileHandle);
documentHasFile = true;
markSaved();
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') return;
- // A stored handle may have lost its permission or its file: prompt for a new one.
- // Every other error is reported, not papered over.
+ // A stored handle may have lost its permission or its file: prompt for a new one,
+ // in the document's own format. Every other error is reported, not papered over.
const name = (err as DOMException)?.name;
- if (name === 'NotAllowedError' || name === 'NotFoundError') { fileHandle = null; return handleSave(); }
+ if (fileHandle && (name === 'NotAllowedError' || name === 'NotFoundError')) { fileHandle = null; return handleSave(); }
console.error('[save] Failed to save file:', err);
failed(t().dialogs.couldNotSave, err);
}
}
- // Save As offers both formats in one picker, so the chosen extension — not the
- // format the document arrived in — decides what is written and what it becomes.
- async function handleSaveAs() {
+ // Save As is one entry per format: the format has to be settled before the bytes are
+ // built (a download cannot read a name back), and it is the document's from then on.
+ async function handleSaveAs(kind: DocumentFormat) {
if (!editor) return;
exportMenuOpen = false;
if (!(await ensurePassword())) return;
const json = editor.getJSON() as TiptapNode;
- const suggested = documentFormat === 'docx' ? suggestedFilenameDocx(json) : suggestedFilename(json);
+ const name = kind === 'docx' ? suggestedFilenameDocx(json) : suggestedFilename(json);
try {
- const { handle, kind } = await saveAsDocument((k) => buildBytes(k, json), suggested, documentFormat, docPassword);
- fileHandle = handle;
+ fileHandle = await saveDocument(await buildBytes(kind, json), name, kind, null, docPassword);
documentFormat = kind;
- recentFiles = await rememberRecentFile(handle?.name ?? suggested, handle);
+ recentFiles = await rememberRecentFile(fileHandle?.name ?? name, fileHandle);
documentHasFile = true;
markSaved();
} catch (err) {
@@ -1024,21 +1021,18 @@
}
}
- // Save the document as a template. The picker offers .ott and .dotx, so which
- // exporter runs is only known once a name is chosen; a template is never bound as
- // the current file, exactly as opening one isn't.
+ // A template in the document's own format (.ott, or .dotx for a .docx document). It
+ // is never bound as the current file, exactly as opening one isn't.
async function handleSaveTemplate() {
if (!editor) return;
exportMenuOpen = false;
if (!(await ensurePassword())) return;
const json = editor.getJSON() as TiptapNode;
try {
- await saveAsTemplate(async (kind) => {
- const { odtToOtt, docxToDotx } = await import('./lib/export/template');
- return kind === 'dotx'
- ? docxToDotx(await buildBytes('docx', json))
- : odtToOtt(await buildBytes('odt', json));
- }, stripOdtExtension(suggestedFilename(json)), docPassword);
+ const { odtToOtt, docxToDotx } = await import('./lib/export/template');
+ const kind = documentFormat === 'docx' ? 'dotx' : 'ott';
+ const bytes = kind === 'dotx' ? docxToDotx(await buildBytes('docx', json)) : odtToOtt(await buildBytes('odt', json));
+ await saveDocument(bytes, `${stripOdtExtension(suggestedFilename(json))}.${kind}`, kind, null, docPassword);
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') return;
console.error('[save] Failed to save template:', err);
@@ -1075,25 +1069,6 @@
exportMenuOpen = false;
}
- // Export to Word .docx. The exporter (and the `docx` library) is lazy-loaded so it
- // never enters the initial bundle. Export-style like PDF: always prompt, no handle.
- async function handleSaveDocx() {
- if (!editor || docxBusy) return;
- exportMenuOpen = false;
- if (!(await ensurePassword())) return;
- docxBusy = true;
- try {
- const json = editor.getJSON() as TiptapNode;
- await saveAsDocx(await buildBytes('docx', json), suggestedFilenameDocx(json), docPassword);
- } catch (err) {
- if ((err as DOMException)?.name === 'AbortError') return;
- console.error('[docx] Export failed:', err);
- failed(t().dialogs.couldNotExportDocx, err);
- } finally {
- docxBusy = false;
- }
- }
-
// Lay the document out into A4 pages (Paged.js) and open the print dialog so the
// user can "Save as PDF" — vector text matching the editor, with header/footer.
async function handleExportPdf() {
@@ -1334,14 +1309,12 @@
{namePlaceholder}
{themeMode}
onSelectTheme={selectTheme}
- {docxBusy}
{pdfBusy}
onNew={handleNew}
onNewFromTemplate={() => (templateGalleryOpen = true)}
onOpen={handleOpen}
onSave={handleSave}
onSaveAs={handleSaveAs}
- onSaveDocx={handleSaveDocx}
onSaveTemplate={handleSaveTemplate}
recentFiles={recentFiles}
onOpenRecent={(id) => { const f = recentFiles.find((r) => r.id === id); if (f) void handleOpenRecent(f); }}
@@ -1452,13 +1425,11 @@
{t().app.odt}
{t().app.openDocument}
-
- {t().ribbon.saveAs}
- {t().app.saveAsFormats}
+ handleSaveAs('odt')} role="menuitem">
+ {t().ribbon.saveAs} (.odt)
-
- {docxBusy ? t().app.exporting : t().app.wordDocx}
- {t().app.microsoftWord}
+ handleSaveAs('docx')} role="menuitem">
+ {t().ribbon.saveAs} (.docx)
{pdfBusy ? t().app.exporting : t().app.rasterPdf}
diff --git a/src/lib/components/ribbon/Ribbon.svelte b/src/lib/components/ribbon/Ribbon.svelte
index be1d7e7..a6ef296 100644
--- a/src/lib/components/ribbon/Ribbon.svelte
+++ b/src/lib/components/ribbon/Ribbon.svelte
@@ -67,10 +67,9 @@
namePlaceholder = '',
themeMode = 'auto',
onSelectTheme,
- docxBusy = false,
pdfBusy = false,
hasPassword = false,
- onNew, onNewFromTemplate, onOpen, onSave, onSaveAs, onSaveDocx, onSaveTemplate, onExportPdf, onPrintPdf, onPrint, onAbout, onDocProperties, onProtect, onAutoCorrect, onAutoText, onNewComment,
+ onNew, onNewFromTemplate, onOpen, onSave, onSaveAs, onSaveTemplate, onExportPdf, onPrintPdf, onPrint, onAbout, onDocProperties, onProtect, onAutoCorrect, onAutoText, onNewComment,
navigatorOpen = false, onToggleNavigator,
recentFiles = [], onOpenRecent, onForgetRecent,
}: {
@@ -108,14 +107,12 @@
namePlaceholder?: string;
themeMode?: ThemeMode;
onSelectTheme?: (mode: ThemeMode) => void;
- docxBusy?: boolean;
pdfBusy?: boolean;
onNew?: () => void;
onNewFromTemplate?: () => void;
onOpen?: () => void;
onSave?: () => void;
- onSaveAs?: () => void;
- onSaveDocx?: () => void;
+ onSaveAs?: (kind: 'odt' | 'docx') => void;
onSaveTemplate?: () => void;
recentFiles?: { id: string; name: string }[];
onOpenRecent?: (id: string) => void;
@@ -216,15 +213,13 @@
{t().app.save}
- run(onSaveAs)} disabled={!editor || pdfBusy}>
- {t().ribbon.saveAs}
-
+ run(() => onSaveAs?.('odt'))} disabled={!editor || pdfBusy}>
+ {t().ribbon.saveAs} (.odt)
+
+ run(() => onSaveAs?.('docx'))} disabled={!editor || pdfBusy}>
+ {t().ribbon.saveAs} (.docx)
- run(onSaveDocx)} disabled={docxBusy}>
- {docxBusy ? t().app.exporting : t().app.wordDocx}
-
-
run(onExportPdf)} disabled={pdfBusy}>
{pdfBusy ? t().app.exporting : t().app.rasterPdf}
diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md
index 9274655..1c3f5e4 100644
--- a/src/lib/export/CLAUDE.md
+++ b/src/lib/export/CLAUDE.md
@@ -6,12 +6,11 @@ Frames, tables and pagination have their own deep-dives in `docs/architecture/`.
A password set for the document is applied in `saveFile.ts`'s two write primitives, after
every pass here and after the template conversion — see `docs/architecture/encryption.md`.
-**Save As offers both document formats in one picker** (`saveAsDocument`), like the template
-one: the bytes are built only once the extension is known, and that extension is what the
-document round-trips as from then on. Without a picker it keeps the format it had. Both
-extensions sit in **one** picker type: Chrome's macOS save panel shows no format popup and
-admits only the first type's extensions, so a second type can never be chosen there. A
-document without a file saves through this picker too — the first save is Save As.
+**Save As is one entry per format** (`saveDocument`): the format is settled before the bytes
+are built, because the download route (Brave ships with the File System Access API off) cannot
+read a changed name back from the browser's dialog — and Chrome's macOS save panel has no format
+popup and admits only the first picker type's extensions anyway. The chosen format is the
+document's from then on; a template follows the document's format (`.ott`, or `.dotx` for `.docx`).
`saveFile.ts` picks the path: `showSaveFilePicker` where there is one, else a download —
Gecko has none and Brave disables the whole File System Access API by default. The first
@@ -44,7 +43,7 @@ fallback download shows a one-time hint (`edentext-download-hint`) that the brow
- **Text boxes / shapes** (`replaceTextBoxes`/`applyTextBoxes`): a box is inline, so it becomes **two** things. `replaceTextBoxes` (after `replacePageBreaks`, before the inline passes) recurses over every node's content like `replaceImages` and swaps each box for a `TBX`-sentinel `P{i}` run **where it sits in its paragraph** — which rides every odf-kit path, table cells included — while its own blocks are **staged** at top level between `S{i}`…`E{i}` marker paragraphs (U+E008), so every existing pass serializes them unchanged (custom attrs, list styles, inline sentinels, images) and document-order-indexed passes stay aligned. The staging is only a place to serialize in: where a box sits and where its content is written out are no longer the same spot, which is what lets a box in a cell work with no change to the cell machinery. `applyTextBoxes` (after `applyImages`) cuts each `S…E` region out, keeps the serialized blocks, and writes `` (plain box; `svg:height` also emitted for non-auto-grow consumers) or `` + preset `` (`utils/shapes.ts`; see `docs/architecture/frames.md`) over the `P{i}` sentinel, in whatever `` the box really sits in. It mints a `TbxFr{n}` graphic style (fill/stroke/padding + the image wrap props; `draw:auto-grow-height="true"` for boxes, both auto-grows explicitly **false** for shapes or LibreOffice's autofit shrinks them to their text). A plain box's style chains to **`style:parent-style-name="Frame"`** — that parent is what makes LibreOffice read it as a Writer text *frame*; without it it is a drawing object whose text loses lists, nested images and borders (probed on a re-save). It is also why an **as-char** box's style spells out `style:vertical-pos="top" style:vertical-rel="baseline"`: under that parent a frame naming no vertical position hangs below the line, where a style-less as-char frame (an image) stands on it. Frames take `fo:background-color` (written beside `draw:fill`) and `fo:border` — `draw:stroke` is ignored there — and vertical text as `style:writing-mode` in the graphic properties; a *shape* instead needs ` ` (both probed). `rewriteStylesXml` declares a minimal `Frame` base style (zeroed margins/padding/border, or the built-in leaks 2mm margins and a hairline). An in-line box has **no horizontal position of its own** — the paragraph it sits in aligns it — so nothing is minted for that; a band-wrapped one carries `wrapAlign` in its graphic style. DOCX is the same pair: `inlineToRuns` drops a marker **run** where the box sits, and `applyTextBoxesDocx` swaps only that run, leaving the paragraph and the text beside it alone.
- **A text box's own parts, DOCX** (`applyTextBoxesDocx`): the `docx` package can't emit a DrawingML shape, so the box XML is a hand-built string — and its content needs parts the package never hears about. The pass holds the whole unzipped package, so it mints them itself: a picture becomes `word/media/tbx{n}.{ext}` plus a `Relationship` above the package's highest `rId` (Content_Types already declares the raster defaults), a list an ``/`` pair above its highest id, appended **before** the first `` because every abstract definition has to precede them. The numbering ids only exist once the packed file is in hand, so the paragraphs carry a `TXBX_NUM` sentinel that the same pass substitutes.
-- **Templates** (`template.ts`): a `.ott`/`.dotx` is the built document with one label changed — ODF's package media type (in `mimetype` **and** in the manifest, the mimetype entry staying first and stored) and OOXML's content type for `/word/document.xml`. `saveAsTemplate` offers both in one picker, so the bytes are only built once the extension is known.
+- **Templates** (`template.ts`): a `.ott`/`.dotx` is the built document with one label changed — ODF's package media type (in `mimetype` **and** in the manifest, the mimetype entry staying first and stored) and OOXML's content type for `/word/document.xml`. The template's format follows the document's (`handleSaveTemplate`).
- **Formulas** (`replaceFormulas`/`applyFormulas`): an `MTH`-sentinel run becomes an as-char ` ` plus a `Formula{n}/content.xml` sub-document (MathML root) and its two manifest entries. **No `svg:width`/`svg:height`** — a sized frame makes LibreOffice scale the object instead of typesetting it. DOCX emits `` (or `` when displayed) from the same post-pack pass as the text boxes. See `docs/architecture/formulas.md`.
- **Right-to-left pages**: ODF gets `style:writing-mode="rl-tb"` on the page layout (`rewriteStylesXml`), DOCX a ` ` in every `w:sectPr` from a post-pack pass (`applyBidiDocx`) — the package exposes only the paragraph-level flag. A **paragraph's** own direction (`textDirection.ts`) rides that same flag in DOCX and the `ParaStyle`/PBX-spec pipe in ODF, so it reaches cells and list items as well as top-level blocks.
- **Mirror margins** (`applyMirrorMarginsDocx`, `export/docx.ts`): the `docx` package writes only the per-section `w:pgMar`, so Word's document-level `w:mirrorMargins` goes into `word/settings.xml` in the same post-pack pass; ODF's flag rides `rewriteStylesXml`. Both formats already hold left/right as the inner/outer pair, so only the flag has to travel.
diff --git a/src/lib/export/saveFile.ts b/src/lib/export/saveFile.ts
index dd3a84e..5ef4f39 100644
--- a/src/lib/export/saveFile.ts
+++ b/src/lib/export/saveFile.ts
@@ -11,23 +11,19 @@ const ODT_MIME = 'application/vnd.oasis.opendocument.text';
const OTT_MIME = 'application/vnd.oasis.opendocument.text-template';
const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
-const DOCX_PICKER_TYPES = [
- { description: 'Word Document', accept: { [DOCX_MIME]: ['.docx'] } },
-];
-
-// Both document formats as ONE picker type: Chrome's macOS save panel shows no format
-// popup and admits only the first type's extensions, so a second type is unreachable.
-// The extension typed decides which is written.
-const DOCUMENT_PICKER_TYPES = [
- { description: 'Document', accept: { [ODT_MIME]: ['.odt'], [DOCX_MIME]: ['.docx'] } },
-];
-
const DOTX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.template';
-// Both template formats in one picker type, for the same reason as the documents.
-const TEMPLATE_PICKER_TYPES = [
- { description: 'Template', accept: { [OTT_MIME]: ['.ott'], [DOTX_MIME]: ['.dotx'] } },
-];
+type Kind = 'odt' | 'docx' | 'ott' | 'dotx';
+
+// One picker type per format, and the format is settled before the bytes are built: the
+// download route cannot read a changed name back from the browser's dialog, and Chrome's
+// macOS panel has no format popup, so a second type in one picker is unreachable anyway.
+const FORMATS: Record } }> = {
+ odt: { mime: ODT_MIME, type: { description: 'OpenDocument Text', accept: { [ODT_MIME]: ['.odt'] } } },
+ docx: { mime: DOCX_MIME, type: { description: 'Word Document', accept: { [DOCX_MIME]: ['.docx'] } } },
+ ott: { mime: OTT_MIME, type: { description: 'OpenDocument Text Template', accept: { [OTT_MIME]: ['.ott'] } } },
+ dotx: { mime: DOTX_MIME, type: { description: 'Word Template', accept: { [DOTX_MIME]: ['.dotx'] } } },
+};
// The open picker also accepts templates; opening one never binds it as the file.
const OPEN_PICKER_TYPES = [
@@ -48,7 +44,7 @@ export function supportsFsAccess(): boolean {
// Where the browser saves is the browser's business (its download folder, or its own
// dialog where that is switched on). Gecko needs the anchor in the document and the
// blob URL alive past the click.
-function download(bytes: Uint8Array, name: string, mime: string = ODT_MIME): void {
+function download(bytes: Uint8Array, name: string, mime: string): void {
// Before the click: the browser's own save dialog would open on top of it.
if (!localStorage.getItem(HINT_KEY)) {
localStorage.setItem(HINT_KEY, '1');
@@ -80,64 +76,24 @@ async function protect(bytes: Uint8Array, password: string | null): Promise {
- await writeHandle(handle, await protect(bytes, password));
-}
-
-// Always prompt for a location, in either document format. Which exporter runs is
-// only known once a name is picked, so `build` is called with the chosen extension.
-// Without a picker the document keeps the format it already has.
-export async function saveAsDocument(
- build: (kind: 'odt' | 'docx') => Promise,
- suggestedName: string,
- fallback: 'odt' | 'docx',
- password: string | null = null,
-): Promise<{ handle: FileSystemFileHandle | null; kind: 'odt' | 'docx' }> {
- if (!supportsFsAccess()) {
- download(await protect(await build(fallback), password), suggestedName, fallback === 'docx' ? DOCX_MIME : ODT_MIME);
- return { handle: null, kind: fallback };
- }
- const handle = await (window as WinFs).showSaveFilePicker!({ suggestedName, types: DOCUMENT_PICKER_TYPES });
- const kind = handle.name.toLowerCase().endsWith('.docx') ? 'docx' : 'odt';
- await writeHandle(handle, await protect(await build(kind), password));
- return { handle, kind };
-}
-
-// Export a .docx copy: always prompt for a location, no handle is tracked (this is
-// the explicit "Export" action, like PDF). Throws AbortError if cancelled.
-export async function saveAsDocx(
+// Save in the given format: into the handle where there is one, else to a location asked
+// for in that format alone, or a download where there is no picker. Returns the handle
+// written to (null for a download). Throws AbortError if the user cancels.
+export async function saveDocument(
bytes: Uint8Array,
suggestedName: string,
+ kind: Kind,
+ handle: FileSystemFileHandle | null = null,
password: string | null = null,
-): Promise {
+): Promise {
const out = await protect(bytes, password);
- if (!supportsFsAccess()) return download(out, suggestedName, DOCX_MIME);
- await writeHandle(await (window as WinFs).showSaveFilePicker!({ suggestedName, types: DOCX_PICKER_TYPES }), out);
-}
-
-// Save a template. The picker offers both formats, so the bytes can only be built
-// once the user has picked one: `build` is called with the chosen extension. Falls
-// back to a plain .ott download where there is no picker.
-export async function saveAsTemplate(
- build: (kind: 'ott' | 'dotx') => Promise,
- baseName: string,
- password: string | null = null,
-): Promise {
if (!supportsFsAccess()) {
- download(await protect(await build('ott'), password), `${baseName}.ott`, OTT_MIME);
- return;
+ download(out, suggestedName, FORMATS[kind].mime);
+ return null;
}
- const handle = await (window as WinFs).showSaveFilePicker!({
- suggestedName: `${baseName}.ott`, types: TEMPLATE_PICKER_TYPES,
- });
- const kind = handle.name.toLowerCase().endsWith('.dotx') ? 'dotx' : 'ott';
- await writeHandle(handle, await protect(await build(kind), password));
+ const target = handle ?? (await (window as WinFs).showSaveFilePicker!({ suggestedName, types: [FORMATS[kind].type] }));
+ await writeHandle(target, out);
+ return target;
}
// Prompt for an .odt/.ott/.docx to open, capturing its handle so a later save can
diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts
index 179a1dd..6c9fee2 100644
--- a/src/lib/i18n/locales/de.ts
+++ b/src/lib/i18n/locales/de.ts
@@ -1041,18 +1041,15 @@ const de: Messages = {
save: 'Speichern',
saveExport: 'Speichern / Exportieren',
unsavedChanges: 'Nicht gespeichert',
- saveAsFormats: '.odt oder .docx',
odt: 'ODT',
openDocument: 'OpenDocument',
- wordDocx: 'Word (.docx)',
exporting: 'Wird exportiert…',
- microsoftWord: 'Microsoft Word',
rasterPdf: 'Raster-PDF',
rasterHint: 'Exakte Kopie des Editors',
vectorPdf: 'Vektor-PDF (Beta)',
vectorHint: 'Scharf, aber Probleme mit Tabellen',
template: 'Vorlage',
- templateHint: '.ott / .dotx — öffnet sich als neues Dokument',
+ templateHint: 'wie das Dokument: .ott oder .dotx — öffnet sich als neues Dokument',
recentFiles: 'Zuletzt verwendet',
clearRecentFiles: 'Liste leeren',
print: 'Drucken',
@@ -1086,7 +1083,6 @@ const de: Messages = {
couldNotOpen: 'Diese Datei konnte nicht geöffnet werden.',
couldNotSave: 'Diese Datei konnte nicht gespeichert werden.',
scriptBlocked: 'Ein Teil der App konnte nicht geladen werden — ein Skript-Blocker (uBlock Origin, NoScript, strenger Schutzmodus) verhindert das. Erlaube diese Seite und versuche es erneut.',
- couldNotExportDocx: 'Export nach Word (.docx) nicht möglich.',
couldNotExportPdf: 'PDF-Export nicht möglich.',
recentUnavailable: (name) =>
`„${name}“ konnte nicht erneut geöffnet werden — die Datei wurde offenbar verschoben oder gelöscht. Öffne sie stattdessen über den Dateidialog.`,
diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts
index 2ab94dc..47078e2 100644
--- a/src/lib/i18n/locales/en.ts
+++ b/src/lib/i18n/locales/en.ts
@@ -1047,18 +1047,15 @@ const en = {
save: 'Save',
saveExport: 'Save / Export',
unsavedChanges: 'Not saved',
- saveAsFormats: '.odt or .docx',
odt: 'ODT',
openDocument: 'OpenDocument',
- wordDocx: 'Word (.docx)',
exporting: 'Exporting…',
- microsoftWord: 'Microsoft Word',
rasterPdf: 'Raster PDF',
rasterHint: 'Exact copy of the editor',
vectorPdf: 'Vector PDF (beta)',
vectorHint: 'Sharp, but problems with tables',
template: 'Template',
- templateHint: '.ott / .dotx — opens as a new document',
+ templateHint: 'as the document: .ott or .dotx — opens as a new document',
recentFiles: 'Recent files',
clearRecentFiles: 'Clear the list',
print: 'Print',
@@ -1092,7 +1089,6 @@ const en = {
couldNotOpen: 'Could not open this file.',
couldNotSave: 'Could not save this file.',
scriptBlocked: 'Part of the app could not be loaded — a script blocker (uBlock Origin, NoScript, strict privacy mode) is blocking it. Allow this site and try again.',
- couldNotExportDocx: 'Could not export to Word (.docx).',
couldNotExportPdf: 'Could not export to PDF.',
recentUnavailable: (name: string) =>
`"${name}" could not be opened again — the file seems to have been moved or deleted. Open it from the file picker instead.`,
diff --git a/tests/dom/run.mjs b/tests/dom/run.mjs
index 2bc669f..84e95f9 100644
--- a/tests/dom/run.mjs
+++ b/tests/dom/run.mjs
@@ -3,6 +3,7 @@
// the page count is what every layer on the page is measured against. Fails on any
// uncaught page error.
import { join } from 'node:path';
+import { readFile } from 'node:fs/promises';
import { ROOT, MOD, checker, previewServer, openApp } from '../browser.mjs';
const PORT = +(process.env.DOM_PORT ?? 4185);
@@ -117,6 +118,19 @@ try {
await page.fill('.doc-name-input', 'Umbenannt');
const afterRename = await dot(true);
check(!reopened && afterRename, `a rename marks the document unsaved (${afterRename})`);
+
+ // Without the File System Access API (Brave ships with it off) a save is a download, so
+ // the format is settled before the bytes are built: Save As (.docx) hands over a DOCX
+ // whatever the browser's dialog does with the name.
+ await page.evaluate(() => { window.showSaveFilePicker = undefined; });
+ await page.click('.ribbon-tab-file');
+ const [download] = await Promise.all([
+ page.waitForEvent('download', { timeout: 30_000 }),
+ page.locator('.ribbon-menu button', { hasText: '(.docx)' }).first().click(),
+ ]);
+ const saved = await readFile(await download.path());
+ const isDocx = saved[0] === 0x50 && saved[1] === 0x4b && saved.includes('word/document.xml');
+ check(isDocx, `Save As (.docx) without a picker downloads a DOCX (${download.suggestedFilename()}, ${saved.length} bytes)`);
} catch (err) {
check(false, `dom run threw: ${err.message ?? err}`);
} finally {
diff --git a/tests/unit/save-as-format.test.ts b/tests/unit/save-as-format.test.ts
index 31fa538..f0875c4 100644
--- a/tests/unit/save-as-format.test.ts
+++ b/tests/unit/save-as-format.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
-import { saveAsDocument, saveAsTemplate } from '../../src/lib/export/saveFile';
+import { saveDocument } from '../../src/lib/export/saveFile';
// A handle that records what was written to it, like the picker hands back.
function handleFor(name: string) {
@@ -9,63 +9,36 @@ function handleFor(name: string) {
written,
};
}
+const win = window as unknown as Record;
+const bytes = new Uint8Array([1, 2]);
-async function saveAs(pickerName: string | null, fallback: 'odt' | 'docx') {
- const picked = pickerName ? handleFor(pickerName) : null;
- const win = window as unknown as Record;
- if (picked) win.showSaveFilePicker = async () => picked.handle;
- else delete win.showSaveFilePicker;
- const built: string[] = [];
- const res = await saveAsDocument(async (kind) => { built.push(kind); return new Uint8Array([1, 2]); },
- `doc.${fallback}`, fallback);
- return { built, kind: res.kind, handle: res.handle, written: picked?.written };
-}
-
-describe('Save As builds the format the picker chose', () => {
- it('writes .docx when the chosen name says so', async () => {
- const r = await saveAs('report.docx', 'odt');
- expect(r.built).toEqual(['docx']);
- expect(r.kind).toBe('docx');
- expect(r.written?.[0]).toEqual(new Uint8Array([1, 2]));
- });
-
- it('writes .odt for a document that came in as .docx', async () => {
- const r = await saveAs('report.odt', 'docx');
- expect(r.built).toEqual(['odt']);
- expect(r.kind).toBe('odt');
- });
-
- it('keeps the document format where there is no picker', async () => {
- vi.stubGlobal('alert', () => {});
- URL.createObjectURL = () => 'blob:x';
- URL.revokeObjectURL = () => {};
- const r = await saveAs(null, 'docx');
- expect(r.built).toEqual(['docx']);
- expect(r.kind).toBe('docx');
- expect(r.handle).toBeNull();
- });
-});
-
-// Chrome's macOS save panel has no format popup and admits only the first type's
-// extensions, so both must live in that one type or the second is unreachable.
-describe('the save pickers keep both extensions in one type', () => {
- const typesOf = async (run: () => Promise) => {
+describe('saveDocument', () => {
+ it('asks for a location in the chosen format alone', async () => {
+ const picked = handleFor('report.docx');
let types: { accept: Record }[] = [];
- (window as unknown as Record).showSaveFilePicker = async (o: { types: typeof types }) => {
- types = o.types;
- return handleFor('x.odt').handle;
- };
- await run();
- return { count: types.length, exts: types.flatMap((t) => Object.values(t.accept).flat()) };
- };
-
- it('for documents', async () => {
- const r = await typesOf(() => saveAsDocument(async () => new Uint8Array(), 'doc.odt', 'odt'));
- expect(r).toEqual({ count: 1, exts: ['.odt', '.docx'] });
+ win.showSaveFilePicker = async (o: { types: typeof types }) => { types = o.types; return picked.handle; };
+ const handle = await saveDocument(bytes, 'report.docx', 'docx');
+ expect(types.flatMap((t) => Object.values(t.accept).flat())).toEqual(['.docx']);
+ expect(handle).toBe(picked.handle);
+ expect(picked.written[0]).toEqual(bytes);
});
- it('for templates', async () => {
- const r = await typesOf(() => saveAsTemplate(async () => new Uint8Array(), 'doc'));
- expect(r).toEqual({ count: 1, exts: ['.ott', '.dotx'] });
+ it('writes into the handle it is given without asking', async () => {
+ const own = handleFor('mine.odt');
+ win.showSaveFilePicker = async () => { throw new Error('asked'); };
+ await saveDocument(bytes, 'mine.odt', 'odt', own.handle as unknown as FileSystemFileHandle);
+ expect(own.written[0]).toEqual(bytes);
+ });
+
+ // Brave ships without the File System Access API: a download in the format's own MIME
+ // type, since the browser's dialog cannot hand a changed name back.
+ it('downloads in the chosen format where there is no picker', async () => {
+ delete win.showSaveFilePicker;
+ vi.stubGlobal('alert', () => {});
+ let blob: Blob | undefined;
+ URL.createObjectURL = (b: Blob) => { blob = b; return 'blob:x'; };
+ URL.revokeObjectURL = () => {};
+ expect(await saveDocument(bytes, 'report.docx', 'docx')).toBeNull();
+ expect(blob?.type).toBe('application/vnd.openxmlformats-officedocument.wordprocessingml.document');
});
});