From 1abdb428babcef375d3ad257b642f4c98f451582 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sat, 11 Jul 2026 12:57:55 +0200 Subject: [PATCH] feat: ott support --- CHANGELOG.md | 1 + src/App.svelte | 7 +++++-- src/lib/export/saveFile.ts | 13 ++++++++++--- src/lib/storage/documentName.ts | 4 ++-- tests/unit/strip-odt-extension.test.ts | 17 +++++++++++++++++ 5 files changed, 35 insertions(+), 7 deletions(-) create mode 100644 tests/unit/strip-odt-extension.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bb59b9a..c756908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,7 @@ **File & export** - ODT export via odf-kit with extensive content.xml / styles.xml post-processing (custom node types, tables with borders, images, fonts and colors); filename derived from the first heading (fallback `document.odt`) - ODT import / open existing file: parses content.xml / styles.xml directly, adopts the file's margins and orientation, and reports graceful-degradation warnings +- Opens `.ott` templates (OpenDocument Text Template): read like an `.odt`, but as a new untitled document — the first Save writes a fresh `.odt` and never overwrites the template (Word/LibreOffice behavior) - Word (.docx) export and import — round-trips the editor's formatting (text, fonts, lists, tables, images, headers/footers, page geometry) and opens real Word documents - PDF export — Raster (pixel-exact copy of the editor with a selectable text layer) and Vector (crisp, fonts embedded, via the browser print dialog) - Print (printer button / Ctrl+P) — opens the browser print dialog with a pixel-exact raster of the document (tables, headers/footers and page breaks intact) diff --git a/src/App.svelte b/src/App.svelte index 3c21d85..6c93c93 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -335,6 +335,9 @@ if (!editor) return; try { const isDocx = sourceName?.toLowerCase().endsWith('.docx'); + // An .ott is a template: load its content but don't bind the handle, so the + // first Save prompts for a new .odt instead of overwriting the template. + const isTemplate = sourceName?.toLowerCase().endsWith('.ott'); // Pre-decode any images in a format the browser can't render (TIFF, …) to PNG. // Lazy: the decoder loads only when such an image is present, else this is a no-op. const converted = await convertUnsupportedImages(bytes); @@ -364,7 +367,7 @@ header: result.headerDistanceCm ?? DEFAULT_HF_DISTANCES.header, footer: result.footerDistanceCm ?? DEFAULT_HF_DISTANCES.footer, }; - fileHandle = handle; + fileHandle = isTemplate ? null : handle; if (result.warnings.length) { console.warn('[import] Unsupported content in opened file:', result.warnings); @@ -779,7 +782,7 @@ diff --git a/src/lib/export/saveFile.ts b/src/lib/export/saveFile.ts index 3f3b4a5..33a00b3 100644 --- a/src/lib/export/saveFile.ts +++ b/src/lib/export/saveFile.ts @@ -2,6 +2,7 @@ // plain browser download / no-op where the API is unavailable (Firefox/Safari). 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 PICKER_TYPES = [ @@ -12,6 +13,12 @@ const DOCX_PICKER_TYPES = [ { description: 'Word Document', accept: { [DOCX_MIME]: ['.docx'] } }, ]; +// The open picker also accepts .ott templates (read-only; saving stays .odt-only). +const OPEN_PICKER_TYPES = [ + { description: 'OpenDocument Text', accept: { [ODT_MIME]: ['.odt'], [OTT_MIME]: ['.ott'] } }, + ...DOCX_PICKER_TYPES, +]; + // showSaveFilePicker/showOpenFilePicker are not in lib.dom yet; reach them via casts. type WinFs = Window & { showSaveFilePicker?: (opts: unknown) => Promise; @@ -81,10 +88,10 @@ export async function saveAsDocx(bytes: Uint8Array, suggestedName: string): Prom await writeHandle(handle, bytes); } -// Prompt for an .odt/.docx to open, capturing its handle so a later save can overwrite -// the same file. Returns null if cancelled. Only call when supportsFsAccess(). +// Prompt for an .odt/.ott/.docx to open, capturing its handle so a later save can +// overwrite the same file. Returns null if cancelled. Only call when supportsFsAccess(). export async function openOdt(): Promise<{ bytes: Uint8Array; handle: FileSystemFileHandle; name: string } | null> { - const [handle] = await (window as WinFs).showOpenFilePicker!({ types: [...PICKER_TYPES, ...DOCX_PICKER_TYPES], multiple: false }); + const [handle] = await (window as WinFs).showOpenFilePicker!({ types: OPEN_PICKER_TYPES, multiple: false }); if (!handle) return null; const file = await handle.getFile(); return { bytes: new Uint8Array(await file.arrayBuffer()), handle, name: file.name }; diff --git a/src/lib/storage/documentName.ts b/src/lib/storage/documentName.ts index 804986f..c2f7946 100644 --- a/src/lib/storage/documentName.ts +++ b/src/lib/storage/documentName.ts @@ -11,9 +11,9 @@ export function saveDocName(name: string): void { localStorage.setItem(KEY, name); } -// Drop a trailing .odt (case-insensitive) so the field shows just the name. +// Drop a trailing .odt or .ott (case-insensitive) so the field shows just the name. export function stripOdtExtension(name: string): string { - return name.replace(/\.odt$/i, ''); + return name.replace(/\.o[dt]t$/i, ''); } // Strip filesystem-illegal characters; keep spaces so user-typed titles read diff --git a/tests/unit/strip-odt-extension.test.ts b/tests/unit/strip-odt-extension.test.ts new file mode 100644 index 0000000..f7daf41 --- /dev/null +++ b/tests/unit/strip-odt-extension.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { stripOdtExtension } from '../../src/lib/storage/documentName'; + +describe('stripOdtExtension', () => { + it('drops a trailing .odt or .ott (case-insensitive)', () => { + expect(stripOdtExtension('Foo.odt')).toBe('Foo'); + expect(stripOdtExtension('Foo.ott')).toBe('Foo'); + expect(stripOdtExtension('Report.ODT')).toBe('Report'); + expect(stripOdtExtension('Report.OTT')).toBe('Report'); + }); + + it('leaves other names and extensions untouched', () => { + expect(stripOdtExtension('Foo.docx')).toBe('Foo.docx'); + expect(stripOdtExtension('my.odt.notes')).toBe('my.odt.notes'); + expect(stripOdtExtension('plain')).toBe('plain'); + }); +});