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:
1 parent
71c7b53281
commit
98d0d5c878
14 files changed
+346
-10
No files matched your search
+2
-2
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 & 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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user