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.
This commit is contained in:
Steffen Becker committed 2026-08-14 00:44:57 +02:00
1 parent 71c7b53281
commit 98d0d5c878
14 files changed
+346 -10

No files matched your search

+2 -2
View File
@@ -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)
+17 -3
View File
@@ -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<typeof buildOdt>[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<typeof buildOdt>[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<typeof buildOdt>[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)}
/>
</div>
{:else}
@@ -985,6 +994,10 @@
<span>{t().app.vectorPdf}</span>
<span class="theme-option-hint">{t().app.vectorHint}</span>
</button>
<div class="theme-heading">{t().docProps.title}</div>
<button class="theme-option" onclick={() => { exportMenuOpen = false; docPropsOpen = true; }} role="menuitem">
<span>{t().docProps.title}</span>
</button>
</div>
{/if}
</div>
@@ -1220,6 +1233,7 @@
</footer>
<AboutDialog bind:open={aboutOpen} />
<DocPropertiesDialog bind:open={docPropsOpen} props={docProps} onApply={(p) => { docProps = p; saveDocProperties(p); }} />
<!-- One instance for every entry point (styles gallery, insert-table menu): the
callers only say which family to land on. -->
<StyleManagerDialog bind:open={styleManagerOpen} family={styleManagerFamily} editor={activeEditor} />
@@ -0,0 +1,132 @@
<script lang="ts">
import { EMPTY_DOC_PROPERTIES, type DocProperties } from '../storage/docProperties';
import { t } from '../i18n/i18n.svelte';
// LibreOffice's File ▸ Properties ▸ Description, Word's File ▸ Info. The values
// travel as ODF meta.xml and DOCX docProps/core.xml.
let { open = $bindable(false), props, onApply }: {
open?: boolean;
props: DocProperties;
onApply: (next: DocProperties) => void;
} = $props();
let dialogEl = $state<HTMLDialogElement | null>(null);
let draft = $state<DocProperties>({ ...EMPTY_DOC_PROPERTIES });
$effect(() => {
const el = dialogEl;
if (!el) return;
if (open && !el.open) { draft = { ...props }; el.showModal(); }
else if (!open && el.open) el.close();
});
const FIELDS = [
{ key: 'title', label: () => t().docProps.docTitle },
{ key: 'subject', label: () => t().docProps.subject },
{ key: 'author', label: () => t().docProps.author },
{ key: 'keywords', label: () => t().docProps.keywords, hint: () => t().docProps.keywordsHint },
] as const;
function apply() {
onApply({ ...draft });
open = false;
}
</script>
<dialog
bind:this={dialogEl}
onclose={() => (open = false)}
onclick={(e) => e.target === dialogEl && (open = false)}
aria-label={t().docProps.title}
>
<div class="body">
<h2>{t().docProps.title}</h2>
{#each FIELDS as f}
<label class="row">
<span>{f.label()}</span>
<input value={draft[f.key]} placeholder={'hint' in f ? f.hint() : ''} oninput={(e) => (draft[f.key] = e.currentTarget.value)} />
</label>
{/each}
<label class="row area">
<span>{t().docProps.description}</span>
<textarea rows="3" value={draft.description} oninput={(e) => (draft.description = e.currentTarget.value)}></textarea>
</label>
<div class="actions">
<button class="reset" onclick={() => (draft = { ...EMPTY_DOC_PROPERTIES })}>{t().docProps.clear}</button>
<span class="spacer"></span>
<button onclick={() => (open = false)}>{t().common.cancel}</button>
<button class="primary" onclick={apply}>{t().common.ok}</button>
</div>
</div>
</dialog>
<style>
dialog {
/* The global reset zeroes every margin, which also takes the auto centring a
modal <dialog> gets by default. */
margin: auto;
border: none;
border-radius: 8px;
padding: 0;
background: var(--color-surface);
color: var(--color-text);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
dialog::backdrop { background: rgba(0, 0, 0, 0.35); }
.body {
display: flex;
flex-direction: column;
gap: 10px;
width: 460px;
max-height: 80vh;
overflow-y: auto;
padding: 18px 20px 16px;
font-family: var(--font-sans);
font-size: 0.85rem;
}
h2 { font-size: 1rem; }
.row { display: flex; align-items: center; gap: 8px; }
.row > span { width: 110px; color: var(--color-text-muted); }
.row.area { align-items: flex-start; }
.row.area > span { padding-top: 5px; }
input, textarea {
flex: 1;
min-width: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
padding: 0 6px;
font: inherit;
}
input { height: 26px; }
textarea { padding: 4px 6px; resize: vertical; }
.actions { display: flex; align-items: center; gap: 8px; padding-top: 4px; }
.spacer { flex: 1; }
.actions button {
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
padding: 5px 14px;
font: inherit;
cursor: pointer;
}
.actions button:hover { background: var(--color-btn-hover); }
.actions .primary {
border-color: var(--color-primary);
background: var(--color-primary);
color: #fff;
}
.actions .reset { color: var(--color-text-muted); }
</style>
+5 -1
View File
@@ -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 @@
<Icon name="print" size={16} />{t().app.print}
<span class="menu-key">{withShortcut('Ctrl+P')}</span>
</button>
<button onclick={() => run(onDocProperties)}>
<Icon name="info" size={16} />{t().docProps.title}
</button>
<button onclick={() => run(onAbout)}>
<Icon name="info" size={16} />{t().about.label}
</button>
+9 -2
View File
@@ -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<Uint8Array> {
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 } } : {}),
+25 -2
View File
@@ -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<Uint8Array> {
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<Uint8Array> {
// 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())}</${tag}>` : '');
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 => `<meta:keyword>${escapeXml(k)}</meta:keyword>`).join('')
+ (author ? el('meta:initial-creator', author) + el('dc:creator', author) : '')
+ `<dc:date>${new Date().toISOString()}</dc:date>`;
files['meta.xml'] = strToU8(strFromU8(metaBytes)
.replace('<meta:generator>odf-kit</meta:generator>', `<meta:generator>${GENERATOR}</meta:generator>`)
.replace('</office:meta>', `${fields}</office:meta>`));
return rezipOdt(files);
}
// A document that takes the larger of two adjoining spacings needs LibreOffice's
+10
View File
@@ -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',
+10
View File
@@ -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',
+22
View File
@@ -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<string, Uint8Array>): 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<string, Uint8Array>): 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;
+23
View File
@@ -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<string, string>[]): Record<string, string> {
return out;
}
// meta.xml → the document's descriptive properties (LibreOffice's File ▸ Properties).
function odfDocProperties(files: Record<string, Uint8Array>): 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) {
+2
View File
@@ -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 <style:font-face>: the CSS family runs use,
+1
View File
@@ -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`)
+53
View File
@@ -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<DocProperties>;
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<DocProperties>): Partial<DocProperties> {
const out: Partial<DocProperties> = {};
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);
}
+35
View File
@@ -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('<dc:title>Jahresbericht</dc:title>'), meta);
check('one meta:keyword per keyword', /<meta:keyword>Bilanz<\/meta:keyword><meta:keyword>Prüfung<\/meta:keyword>/.test(meta));
check('ampersand escaped', meta.includes('Entwurf &amp; Vorlage'));
check('generator is ours', meta.includes('<meta:generator>EdenText</meta:generator>'));
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('<dc:title>'), meta);
const back = importOdt(bytes).props;
check('all empty', Object.values(back).every((v) => v === ''), back);
});
});