From 98d0d5c87882bc4112b77ede378db0f2bf1fbb32 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Fri, 14 Aug 2026 00:44:57 +0200 Subject: [PATCH] feat: the document carries its title, subject, author and keywords LibreOffice's File > Properties / Word's File > Info, reachable from both chromes' file menus. ODF writes them into the meta.xml odf-kit already emits (one meta:keyword each, generator EdenText); DOCX passes them to the package's core properties. Both importers read them back, and LibreOffice shows every field from either format. --- CHANGELOG.md | 4 +- src/App.svelte | 20 ++- src/lib/components/DocPropertiesDialog.svelte | 132 ++++++++++++++++++ src/lib/components/ribbon/Ribbon.svelte | 6 +- src/lib/export/docx.ts | 11 +- src/lib/export/odt.ts | 27 +++- src/lib/i18n/locales/de.ts | 10 ++ src/lib/i18n/locales/en.ts | 10 ++ src/lib/import/docx.ts | 22 +++ src/lib/import/odt.ts | 23 +++ src/lib/import/styleResolver.ts | 2 + src/lib/storage/CLAUDE.md | 1 + src/lib/storage/docProperties.ts | 53 +++++++ tests/roundtrip.test.ts | 35 +++++ 14 files changed, 346 insertions(+), 10 deletions(-) create mode 100644 src/lib/components/DocPropertiesDialog.svelte create mode 100644 src/lib/storage/docProperties.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ef03b1e..ea07d8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ - Headings H1–H6 with LibreOffice's sizes (18/16/14/13/12/12 pt, levels 4 and 6 italic), including un-bolding a heading **Styles** -- Named paragraph styles with LibreOffice's inheritance model: a style has a parent, a follow-on style and its own properties; changing a style updates every paragraph using it. Built-ins Default text → Heading → Heading 1–5 / Title / Subtitle / Quote, with LibreOffice's values (18/16/14/13/12 pt headings) +- Named paragraph styles with LibreOffice's inheritance model: a style has a parent, a follow-on style and its own properties; changing a style updates every paragraph using it. Built-ins Default text → Heading → Heading 1–6 / Title / Subtitle / Quote, with LibreOffice's values (18/16/14/13/12/12 pt headings) - Named character styles (Emphasis / Strong Emphasis / Source Text + own ones) as a mark on a text run - Style gallery in the toolbar lists both families; assigning a style keeps hard formatting (Word/LibreOffice behavior), Ctrl+M clears direct formatting but keeps the style - Style manager ("Manage styles…"): inheritance tree, live property editing (empty field = inherit again), new / update from selection, rename, delete, reset a built-in — for paragraph and character styles alike @@ -109,6 +109,7 @@ - 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) - Embedded font loading: fonts embedded in an opened `.odt`/`.docx` (Word `.odttf` de-obfuscated) are registered via the FontFace API so text renders in its real face even when the font isn't installed; persisted per-document in IndexedDB so it survives a reload. Fonts the document only names but neither embeds nor installs are still flagged as substituted - 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 +- Document properties (title, subject, author, keywords, comments) in the File menu — LibreOffice's File ▸ Properties / Word's File ▸ Info. Round-trips through ODF `meta.xml` (one `meta:keyword` per keyword) and DOCX `docProps/core.xml` - 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) @@ -128,7 +129,6 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-08. - Automatic hyphenation: `fo:hyphenate` / `w:hyphenationZone` are ignored and the editor sets no `hyphens`, so a justified paragraph breaks its lines differently than LibreOffice — a systematic source of page-count drift in render parity - Captions with numbered figures/tables ("Abbildung 1: …") and the lists built from them (list of figures / tables). The TOC machinery exists, the other index families do not - Page numbering options: the page field is decimal-only — no start value, no roman/alpha format, no restart per section -- Document properties: title, author, subject, keywords. No `meta.xml` is written and the DOCX creator is hard-coded, so an exported file carries no authorship - Heading levels 7–10 (Word and LibreOffice go that far; HTML stops at `h6`) - Alphabetical index and bibliography - Line numbering (LibreOffice Tools ▸ Line numbering / Word Layout ▸ Line numbers) diff --git a/src/App.svelte b/src/App.svelte index 574ce2c..b6430d6 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -35,11 +35,13 @@ import { builtinStyleSheet, type StyleFamily } from './lib/styles/styleSheet'; import { loadHfDoc, saveHfDoc, loadHfDistances, saveHfDistances, loadDifferentFirstPage, saveDifferentFirstPage, loadDifferentOddEven, saveDifferentOddEven, hfIsEmpty, DEFAULT_HF_DISTANCES, loadExtraHfSections, saveExtraHfSections, type HfDoc, type HfZone, type HfDistances, type HfSet } from './lib/storage/headerFooter'; import { loadDocName, saveDocName, stripOdtExtension, sanitizeNameForFile } from './lib/storage/documentName'; + import { loadDocProperties, saveDocProperties, EMPTY_DOC_PROPERTIES, type DocProperties } from './lib/storage/docProperties'; import { loadDocumentLanguage, saveDocumentLanguage, odfFromLanguage, type DocumentLanguage } from './lib/storage/documentLanguage'; import { spellController } from './lib/spell/controller'; import LanguagePicker from './lib/components/LanguagePicker.svelte'; import UiLanguagePicker from './lib/components/UiLanguagePicker.svelte'; import AboutDialog from './lib/components/AboutDialog.svelte'; + import DocPropertiesDialog from './lib/components/DocPropertiesDialog.svelte'; import StyleManagerDialog from './lib/components/StyleManagerDialog.svelte'; import NoteOptionsDialog from './lib/components/NoteOptionsDialog.svelte'; import { t } from './lib/i18n/i18n.svelte'; @@ -147,6 +149,8 @@ // 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()); + let docProps: DocProperties = $state(loadDocProperties()); + let docPropsOpen = $state(false); // Width of the hidden mirror span (below), so the title input grows/shrinks // with its text instead of sitting in a fixed-width box. @@ -483,6 +487,8 @@ hfDistances = { ...DEFAULT_HF_DISTANCES }; extraHfSections = []; documentName = ''; + docProps = { ...EMPTY_DOC_PROPERTIES }; + saveDocProperties(docProps); fileHandle = null; // Styles and note settings live in the document, so a new one starts from the built-ins setStyleSheet(builtinStyleSheet()); @@ -549,6 +555,8 @@ // an imported table finds its style again by name. setStyleSheet({ ...result.styles, table: styleSheet().table }); setNoteSettings(result.notes); + docProps = result.props; + saveDocProperties(docProps); // Adopt header/footer + first-page variants (null clears the zone); end any edit. hfActive = null; headerDoc = result.header; @@ -620,7 +628,7 @@ exportMenuOpen = false; const json = editor.getJSON() as Parameters[0]; try { - const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings()); + const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps); fileHandle = await saveOdt(bytes, suggestedFilename(json), fileHandle); } catch (err) { if ((err as DOMException)?.name === 'AbortError') return; @@ -636,7 +644,7 @@ exportMenuOpen = false; const json = editor.getJSON() as Parameters[0]; try { - const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings()); + const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps); fileHandle = await saveAsOdt(bytes, suggestedFilename(json)); } catch (err) { if ((err as DOMException)?.name === 'AbortError') return; @@ -654,7 +662,7 @@ try { const json = editor.getJSON() as Parameters[0]; const { buildDocx } = await import('./lib/export/docx'); - const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings()); + const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps); await saveAsDocx(bytes, suggestedFilenameDocx(json)); } catch (err) { if ((err as DOMException)?.name === 'AbortError') return; @@ -888,6 +896,7 @@ onPrintPdf={handlePrintPdf} onPrint={handlePrint} onAbout={() => (aboutOpen = true)} + onDocProperties={() => (docPropsOpen = true)} /> {:else} @@ -985,6 +994,10 @@ {t().app.vectorPdf} {t().app.vectorHint} +
{t().docProps.title}
+ {/if} @@ -1220,6 +1233,7 @@ + { docProps = p; saveDocProperties(p); }} /> diff --git a/src/lib/components/DocPropertiesDialog.svelte b/src/lib/components/DocPropertiesDialog.svelte new file mode 100644 index 0000000..356a690 --- /dev/null +++ b/src/lib/components/DocPropertiesDialog.svelte @@ -0,0 +1,132 @@ + + + (open = false)} + onclick={(e) => e.target === dialogEl && (open = false)} + aria-label={t().docProps.title} +> +
+

{t().docProps.title}

+ + {#each FIELDS as f} + + {/each} + + + +
+ + + + +
+
+
+ + diff --git a/src/lib/components/ribbon/Ribbon.svelte b/src/lib/components/ribbon/Ribbon.svelte index b737252..1af798c 100644 --- a/src/lib/components/ribbon/Ribbon.svelte +++ b/src/lib/components/ribbon/Ribbon.svelte @@ -55,7 +55,7 @@ onSelectTheme, docxBusy = false, pdfBusy = false, - onNew, onOpen, onSave, onSaveAs, onSaveDocx, onExportPdf, onPrintPdf, onPrint, onAbout, + onNew, onOpen, onSave, onSaveAs, onSaveDocx, onExportPdf, onPrintPdf, onPrint, onAbout, onDocProperties, }: { editor: Editor | null; tick: number; @@ -92,6 +92,7 @@ onPrintPdf?: () => void; onPrint?: () => void; onAbout?: () => void; + onDocProperties?: () => void; } = $props(); const TABS = ['home', 'insert', 'layout', 'references', 'review', 'view'] as const; @@ -188,6 +189,9 @@ {t().app.print} {withShortcut('Ctrl+P')} + diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index f3d86aa..c097b29 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -29,7 +29,8 @@ import { parseTabStops, type TabAlign } from '../editor/extensions/tabStops'; import { charStyleProps, listMarkerFormat } from '../editor/extensions/listMarker'; import { effectiveOrderedDefAt, formatOrdinal, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes'; import { defaultBulletChar } from '../utils/bulletListTypes'; -import { normalizeColor, MAX_HEADING_LEVEL, mergeJoinedParagraphsJson, twinFontName, type HfExport } from './odt'; +import { normalizeColor, GENERATOR, MAX_HEADING_LEVEL, mergeJoinedParagraphsJson, twinFontName, type HfExport } from './odt'; +import { EMPTY_DOC_PROPERTIES, type DocProperties } from '../storage/docProperties'; import { builtinStyleSheet, DEFAULT_STYLE, resolveStyle, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet'; import { parseTableLook, regionText, type TableStyle } from '../styles/tableStyles'; import { findFormat, renderFormat, docxPicture, localeTag, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT } from '../utils/dateTime'; @@ -1306,6 +1307,7 @@ export async function buildDocx( spacingModel: SpacingModel = 'add', rtl = false, notesSettings: NoteSettings = DEFAULT_NOTE_SETTINGS, + props: DocProperties = EMPTY_DOC_PROPERTIES, ): Promise { docLangTag = localeTag(language ? language.language : 'en'); exportSheet = styles; @@ -1396,7 +1398,12 @@ export async function buildDocx( }; const doc = new Document({ - creator: 'EdenText', + // Word's File ▸ Info; an empty field is left out so it does not overwrite Word's own. + creator: props.author.trim() || GENERATOR, + ...(props.title.trim() ? { title: props.title.trim() } : {}), + ...(props.subject.trim() ? { subject: props.subject.trim() } : {}), + ...(props.keywords.trim() ? { keywords: props.keywords.trim() } : {}), + ...(props.description.trim() ? { description: props.description.trim() } : {}), defaultTabStop: cmToTwip(tabIntervalCm), ...(differentOddEven ? { evenAndOddHeaderAndFooters: true } : {}), ...(hasToc ? { features: { updateFields: true } } : {}), diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index ca42250..a8a86b3 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -7,6 +7,7 @@ import { pageDimsCm, type PageFormat } from '../storage/pageFormat'; import { DEFAULT_TAB_INTERVAL_CM } from '../storage/tabInterval'; import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc, type HfSet } from '../storage/headerFooter'; import { DEFAULT_NOTE_SETTINGS, NOTE_FONT_SIZE_PT, NOTE_INDENT_CM, type NoteKind, type NoteSettings } from '../storage/noteSettings'; +import { EMPTY_DOC_PROPERTIES, keywordList, type DocProperties } from '../storage/docProperties'; import { builtinStyleSheet, DEFAULT_STYLE, resolveStyle, type StyleSheet, type TextProps } from '../styles/styleSheet'; import { TABLE_REGIONS, parseTableLook, regionText, type TableLook, type TableRegion, @@ -202,6 +203,9 @@ export const HEADING_STYLE_OVERRIDES: { name: string; fontSize: string; marginTo // the same trick as EXPORT_FONT for the serif body font. export const HEADING_FONT = 'Arial'; +// What both formats name as the producing application (ODF meta:generator). +export const GENERATOR = 'EdenText'; + // Highest heading level the editor offers (extensions.ts, both importers, TOC). export const MAX_HEADING_LEVEL = HEADING_STYLE_OVERRIDES.length; export const HEADING_LEVELS = HEADING_STYLE_OVERRIDES.map((_, i) => i + 1); @@ -3542,7 +3546,7 @@ export type HfExport = { }; // The full document → .odt pipeline, DOM-free; returns the .odt bytes. -export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null, pageFormat: PageFormat = 'A4', styles: StyleSheet = builtinStyleSheet(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, spacingModel: SpacingModel = 'add', rtl = false, notesSettings: NoteSettings = DEFAULT_NOTE_SETTINGS): Promise { +export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null, pageFormat: PageFormat = 'A4', styles: StyleSheet = builtinStyleSheet(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, spacingModel: SpacingModel = 'add', rtl = false, notesSettings: NoteSettings = DEFAULT_NOTE_SETTINGS, props: DocProperties = EMPTY_DOC_PROPERTIES): Promise { // Images become IMG sentinels before serialization; applyImages resolves them and writes // the Pictures/ + manifest entries. Text boxes and columns hoist after replacePageBreaks // (so PGB misses their blocks) and before the inline passes (which then cover them). @@ -3775,7 +3779,26 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU // Sections past the first get their own master page, which is where ODF keeps a // section's header/footer; the SEC-marked block points at it. const withSections = applySectionMasterPages(withHf, hf?.sections ?? [], hf?.pageCount ?? 1, margins); - return applySpacingModel(withSections, spacingModel); + return applyDocProperties(applySpacingModel(withSections, spacingModel), props); +} + +// The descriptive metadata → meta.xml, which odf-kit already writes (generator + +// creation date). LibreOffice's File ▸ Properties and Word's File ▸ Info read it. +function applyDocProperties(odtBytes: Uint8Array, props: DocProperties): Uint8Array { + const files = unzipSync(odtBytes); + const metaBytes = files['meta.xml']; + if (!metaBytes) return odtBytes; + const el = (tag: string, value: string) => (value.trim() ? `<${tag}>${escapeXml(value.trim())}` : ''); + const author = props.author.trim(); + const fields = el('dc:title', props.title) + el('dc:subject', props.subject) + + el('dc:description', props.description) + + keywordList(props.keywords).map(k => `${escapeXml(k)}`).join('') + + (author ? el('meta:initial-creator', author) + el('dc:creator', author) : '') + + `${new Date().toISOString()}`; + files['meta.xml'] = strToU8(strFromU8(metaBytes) + .replace('odf-kit', `${GENERATOR}`) + .replace('', `${fields}`)); + return rezipOdt(files); } // A document that takes the larger of two adjoining spacings needs LibreOffice's diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index 9d5dcdc..e913fe6 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -12,6 +12,16 @@ const de: Messages = { insert: 'Einfügen', remove: 'Entfernen', }, + docProps: { + title: 'Dokumenteigenschaften', + docTitle: 'Titel', + subject: 'Thema', + author: 'Autor', + keywords: 'Schlüsselwörter', + keywordsHint: 'durch Komma getrennt', + description: 'Kommentare', + clear: 'Alle löschen', + }, appearance: { language: 'Sprache', title: 'Darstellung', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 2be8b57..2ccc5c1 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -13,6 +13,16 @@ const en = { insert: 'Insert', remove: 'Remove', }, + docProps: { + title: 'Document properties', + docTitle: 'Title', + subject: 'Subject', + author: 'Author', + keywords: 'Keywords', + keywordsHint: 'comma-separated', + description: 'Comments', + clear: 'Clear all', + }, appearance: { language: 'Language', title: 'Appearance', diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index a056659..e947a16 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -19,6 +19,7 @@ import { clampTabInterval, DOCX_IMPLIED_TAB_CM } from '../storage/tabInterval'; import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage'; import { EMPTY_HF_SET, type HfDoc, type HfSet } from '../storage/headerFooter'; import { DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteNumFormat, type NoteSettings } from '../storage/noteSettings'; +import { EMPTY_DOC_PROPERTIES, type DocProperties } from '../storage/docProperties'; import { applyUniformRunFont, pairAlignedFrames, sinkOffsetFrames, type OdtImportResult } from './odt'; import { chartDataUrl } from './chart'; import { deobfuscateOdttf, type EmbeddedFont } from '../fonts/embeddedFonts'; @@ -244,6 +245,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages = headerDistanceCm: hasHeader ? sect.headerDistCm : null, footerDistanceCm: hasFooter ? sect.footerDistCm : null, language: documentLanguage(stylesDoc, warnings), + props: docxDocProperties(files), fonts: extractDocxFonts(files), warnings: [...warnings], }; @@ -2214,6 +2216,26 @@ function docTabInterval(files: Record): number { } } +// docProps/core.xml → the document's descriptive properties (Word's File ▸ Info). +const DC_NS = 'http://purl.org/dc/elements/1.1/'; +const CP_NS = 'http://schemas.openxmlformats.org/package/2006/metadata/core-properties'; + +function docxDocProperties(files: Record): DocProperties { + const bytes = files['docProps/core.xml']; + if (!bytes) return { ...EMPTY_DOC_PROPERTIES }; + let doc: Document; + try { doc = parseXml(strFromU8(bytes)); } catch { return { ...EMPTY_DOC_PROPERTIES }; } + const text = (ns: string, name: string) => + doc.getElementsByTagNameNS(ns, name)[0]?.textContent?.trim() ?? ''; + return { + title: text(DC_NS, 'title'), + subject: text(DC_NS, 'subject'), + author: text(DC_NS, 'creator'), + keywords: text(CP_NS, 'keywords'), + description: text(DC_NS, 'description'), + }; +} + function parseSectPr(sect: Element | null, ctx: Ctx, oddEven = false): { margins: PageMargins | null; orientation: Orientation | null; format: PageFormat | null; header: HfDoc; footer: HfDoc; headerFirst: HfDoc; footerFirst: HfDoc; differentFirstPage: boolean; diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 55350ff..62e45f1 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -21,6 +21,7 @@ import { pageDimsCm, type PageFormat } from '../storage/pageFormat'; import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage'; import type { HfDoc, HfSet } from '../storage/headerFooter'; import type { NoteKind, NoteSettings } from '../storage/noteSettings'; +import { EMPTY_DOC_PROPERTIES, type DocProperties } from '../storage/docProperties'; import type { EmbeddedFont } from '../fonts/embeddedFonts'; import { cellPaddingAttr, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding'; import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox'; @@ -84,6 +85,8 @@ export interface OdtImportResult { styles: StyleSheet; // How its footnotes and endnotes are numbered and separated. notes: NoteSettings; + // Title/subject/author/keywords (ODF meta.xml, DOCX docProps/core.xml). + props: DocProperties; warnings: string[]; } @@ -690,6 +693,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = fonts, styles: collectStyleSheet(resolver, ctx), notes: resolver.noteSettings(), + props: odfDocProperties(files), warnings: [...warnings], }; } @@ -814,6 +818,25 @@ function mergeHfBox(maps: Record[]): Record { return out; } +// meta.xml → the document's descriptive properties (LibreOffice's File ▸ Properties). +function odfDocProperties(files: Record): DocProperties { + const bytes = files['meta.xml']; + if (!bytes) return { ...EMPTY_DOC_PROPERTIES }; + let doc: Document; + try { doc = parseXml(strFromU8(bytes)); } catch { return { ...EMPTY_DOC_PROPERTIES }; } + const text = (ns: string, name: string) => + doc.getElementsByTagNameNS(ns, name)[0]?.textContent?.trim() ?? ''; + return { + title: text(NS.dc, 'title'), + subject: text(NS.dc, 'subject'), + // dc:creator is who saved it last, meta:initial-creator who started it. + author: text(NS.dc, 'creator') || text(NS.meta, 'initial-creator'), + keywords: Array.from(doc.getElementsByTagNameNS(NS.meta, 'keyword')) + .map(k => k.textContent?.trim() ?? '').filter(Boolean).join(', '), + description: text(NS.dc, 'description'), + }; +} + function parseXml(xml: string): Document { const doc = new DOMParser().parseFromString(xml, 'application/xml'); if (doc.getElementsByTagName('parsererror').length) { diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index c1c113a..6790c17 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -21,6 +21,8 @@ export const NS = { number: 'urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0', loext: 'urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0', math: 'http://www.w3.org/1998/Math/MathML', + dc: 'http://purl.org/dc/elements/1.1/', + meta: 'urn:oasis:names:tc:opendocument:xmlns:meta:1.0', } as const; // One embedded-font binary referenced from a : the CSS family runs use, diff --git a/src/lib/storage/CLAUDE.md b/src/lib/storage/CLAUDE.md index fddbef5..b6d4ae6 100644 --- a/src/lib/storage/CLAUDE.md +++ b/src/lib/storage/CLAUDE.md @@ -34,6 +34,7 @@ All keys share the `edentext-` prefix — a namespace, not a description: on Git - **Recent fonts:** `edentext-recent-fonts` — JSON string array (ToolbarExpanded). - **Header/footer:** `edentext-header` / `-footer` (HfDoc), `edentext-hf-distances`. - **Styles:** `edentext-styles` — the style registry (`styles/sheet.svelte.ts`). +- **Document properties:** `edentext-doc-properties` — title/subject/author/keywords/comments (`docProperties.ts`); the key is removed when every field is empty, so a fresh document writes nothing into `meta.xml`. - **Notes:** `edentext-notes` — footnote/endnote numbering and the separator (`noteSettings.ts`, reactive singleton in `notes.svelte.ts`); `applyNoteVars` puts the separator on `:root`, where `editor.css` draws it and `pageBreaks.ts` reserves its band. ## Themes (`theme.ts`, `styles/global.css`) diff --git a/src/lib/storage/docProperties.ts b/src/lib/storage/docProperties.ts new file mode 100644 index 0000000..bc33ce9 --- /dev/null +++ b/src/lib/storage/docProperties.ts @@ -0,0 +1,53 @@ +// The document's descriptive metadata — LibreOffice's File ▸ Properties, Word's +// File ▸ Info. Written to ODF meta.xml and DOCX docProps/core.xml, read back on import. + +export type DocProperties = { + title: string; + subject: string; + // ODF dc:creator / meta:initial-creator, Word's dc:creator ("Author"). + author: string; + // Comma-separated in the UI; ODF stores one meta:keyword per keyword. + keywords: string; + // LibreOffice calls it Comments, Word Comments too (dc:description). + description: string; +}; + +const KEY = 'edentext-doc-properties'; + +export const EMPTY_DOC_PROPERTIES: DocProperties = { + title: '', subject: '', author: '', keywords: '', description: '', +}; + +export function loadDocProperties(): DocProperties { + try { + const raw = localStorage.getItem(KEY); + if (!raw) return { ...EMPTY_DOC_PROPERTIES }; + const data = JSON.parse(raw) as Partial; + return { ...EMPTY_DOC_PROPERTIES, ...pickStrings(data) }; + } catch { + return { ...EMPTY_DOC_PROPERTIES }; + } +} + +export function saveDocProperties(props: DocProperties): void { + if (isEmptyDocProperties(props)) localStorage.removeItem(KEY); + else localStorage.setItem(KEY, JSON.stringify(props)); +} + +export function isEmptyDocProperties(props: DocProperties): boolean { + return Object.values(props).every(v => !v.trim()); +} + +// Keeps a file's own values from landing as `undefined` or a number. +function pickStrings(data: Partial): Partial { + const out: Partial = {}; + for (const key of Object.keys(EMPTY_DOC_PROPERTIES) as (keyof DocProperties)[]) { + if (typeof data[key] === 'string') out[key] = data[key]; + } + return out; +} + +// ODF keeps one meta:keyword per keyword, Word one comma-separated string. +export function keywordList(keywords: string): string[] { + return keywords.split(',').map(k => k.trim()).filter(Boolean); +} diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 170044c..24992de 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -1750,3 +1750,38 @@ describe('Leg 12: per-section page margins (ODT + DOCX)', () => { check('section margins round-trip', JSON.stringify(back) === JSON.stringify(wide), back); }); }); + +describe('Leg 13: document properties (meta.xml / docProps/core.xml)', () => { + const propsDoc: N = { type: 'doc', content: [P(null, T('body'))] }; + const props = { + title: 'Jahresbericht', subject: 'Finanzen', author: 'A. Muster', + keywords: 'Bilanz, Prüfung', description: 'Entwurf & Vorlage', + }; + + it('ODT: meta.xml carries the fields and they come back', async () => { + const bytes = await buildOdt(propsDoc, margins, 'portrait', undefined, null, 'A4', + builtinStyleSheet(), 1.25, 'add', false, DEFAULT_NOTE_SETTINGS, props); + const meta = strFromU8(unzipSync(bytes)['meta.xml']); + check('dc:title', meta.includes('Jahresbericht'), meta); + check('one meta:keyword per keyword', /Bilanz<\/meta:keyword>Prüfung<\/meta:keyword>/.test(meta)); + check('ampersand escaped', meta.includes('Entwurf & Vorlage')); + check('generator is ours', meta.includes('EdenText')); + const back = importOdt(bytes).props; + check('round-trips whole', JSON.stringify(back) === JSON.stringify(props), back); + }); + + it('DOCX: docProps/core.xml carries the fields and they come back', async () => { + const bytes = await buildDocx(propsDoc, margins, 'portrait', undefined, null, 'A4', + builtinStyleSheet(), 1.25, 'add', false, DEFAULT_NOTE_SETTINGS, props); + const back = importDocx(bytes).props; + check('round-trips whole', JSON.stringify(back) === JSON.stringify(props), back); + }); + + it('an empty set leaves no fields behind', async () => { + const bytes = await buildOdt(propsDoc, margins, 'portrait'); + const meta = strFromU8(unzipSync(bytes)['meta.xml']); + check('no dc:title', !meta.includes(''), meta); + const back = importOdt(bytes).props; + check('all empty', Object.values(back).every((v) => v === ''), back); + }); +});