feat: recording revisions belongs to the document, not the editor

A new document starts with it off and an import adopts the file's own
flag, so an editor left recording no longer marks the next document it
opens. Both formats carry it, probed against soffice: ODF on the change
registry (text:track-changes, written for the flag alone where nothing
is recorded yet), Word as w:trackRevisions in settings.xml — LibreOffice
writes and reads exactly those two, and reading w:trackChanges instead
would have found nothing.
This commit is contained in:
Steffen Becker committed 2026-08-15 17:07:06 +02:00
1 parent 4e343f2014
commit ad2a01ba75
12 files changed
+124 -16

No files matched your search

+1 -1
View File
@@ -121,7 +121,7 @@
- Right-click context menu for text: cut / copy / paste / paste without formatting, link insert-edit-remove, clear formatting — with the spelling suggestions merged in on top. Shift+right-click keeps the browser's own menu; images, text boxes and header/footer stay with their existing UI
- Spell check in English and German with squiggles and suggestions in the context menu (add / ignore word); selectable document language. Powered by Hunspell (WASM), so German compound words (Fußgänger, Krankenversicherung …) are recognised; dictionaries are lazy-loaded per language on demand
- Word / character count statistics (whole document and selection)
- Reviewing pane for tracked changes (Review ▸ Revisions), beside the comments one: every recorded change in document order — author, date, what was inserted or deleted — click to select the text, accept or reject in place. A change a paragraph boundary splits is one row and is applied whole. Each **author** draws in their own colour (LibreOffice's author palette, handed out in order of first appearance), so several reviewers stay apart on the page
- Reviewing pane for tracked changes (Review ▸ Revisions), beside the comments one: every recorded change in document order — author, date, what was inserted or deleted — click to select the text, accept or reject in place. A change a paragraph boundary splits is one row and is applied whole. Each **author** draws in their own colour (LibreOffice's author palette, handed out in order of first appearance), so several reviewers stay apart on the page. Recording is the **document's** setting, not the editor's: it is read from the file, written back into it, and a new document starts with it off — an opened document is never marked up as the reader's own work
- Navigator pane (F5, or View ▸ Navigator): the document's chapters as an outline, click one to jump to it and the caret's own chapter stays marked. Beside each, LibreOffice's four chapter operations — move up / down and promote / demote a level, each carrying everything under the heading with it. A move swaps with the adjacent chapter at the same level and never crosses a heading above it, so a subheading stays in its own chapter. Below the outline, the tables, pictures and bookmarks LibreOffice's Navigator also lists, as jump targets
- Split view (View ▸ Split, Word's own Ctrl+Alt+S): two panes onto the same document, scrolled independently and both editable — read one page while writing on another, or copy between distant parts. The panes are stacked, as both word processors split a window, so they share a width and the second renders the pagination the first measures rather than laying the document out twice. The divider drags; whichever pane you last worked in keeps the caret, the floating toolbars and the ribbon's commands, and the other one stays where you left it
+9 -4
View File
@@ -34,6 +34,7 @@
import { loadPageFormat, savePageFormat, type PageFormat } from './lib/storage/pageFormat';
import { setStyleSheet, styleSheet } from './lib/styles/sheet.svelte';
import { noteSettings, setNoteSettings } from './lib/storage/notes.svelte';
import { recordChanges, setRecordChanges } from './lib/storage/trackChanges.svelte';
import { DEFAULT_NOTE_SETTINGS } from './lib/storage/noteSettings';
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';
@@ -546,6 +547,8 @@
pageNumbering = { ...DEFAULT_PAGE_NUMBERING };
pageDecor = { ...EMPTY_PAGE_DECOR };
lineNumbering = { ...DEFAULT_LINE_NUMBERING };
// Recording belongs to the document, so a new one starts off, as it does in both.
setRecordChanges(false);
docProps = { ...EMPTY_DOC_PROPERTIES };
saveDocProperties(docProps);
fileHandle = null;
@@ -611,6 +614,8 @@
pageNumbering = result.pageNumbering;
pageDecor = result.decor;
lineNumbering = result.lineNumbering;
// The file says whether it goes on recording; ours is not the setting that counts.
setRecordChanges(result.recordChanges);
// Adopt the document's spell-check language (the $effect switches the
// controller + loads its dictionary). null = file declared none; keep ours.
if (result.language) documentLanguage = result.language;
@@ -723,7 +728,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(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering);
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges());
fileHandle = await saveOdt(bytes, suggestedFilename(json), fileHandle);
recentFiles = await rememberRecentFile(fileHandle?.name ?? suggestedFilename(json), fileHandle);
} catch (err) {
@@ -740,7 +745,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(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering);
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges());
fileHandle = await saveAsOdt(bytes, suggestedFilename(json));
recentFiles = await rememberRecentFile(fileHandle?.name ?? suggestedFilename(json), fileHandle);
} catch (err) {
@@ -759,7 +764,7 @@
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
try {
await saveAsTemplate(async (kind) => {
const args = [pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering] as const;
const args = [pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges()] as const;
const { odtToOtt, docxToDotx } = await import('./lib/export/template');
if (kind === 'dotx') {
const { buildDocx } = await import('./lib/export/docx');
@@ -810,7 +815,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(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering);
const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges());
await saveAsDocx(bytes, suggestedFilenameDocx(json));
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') return;
+1 -1
View File
@@ -34,7 +34,7 @@ Frames, tables and pagination have their own deep-dives in `docs/architecture/`.
- **Mirror margins** (`applyMirrorMarginsDocx`, `export/docx.ts`): the `docx` package writes only the per-section `w:pgMar`, so Word's document-level `w:mirrorMargins` goes into `word/settings.xml` in the same post-pack pass; ODF's flag rides `rewriteStylesXml`. Both formats already hold left/right as the inner/outer pair, so only the flag has to travel.
- **Bookmarks & cross-references** (`replaceBookmarks`/`applyBookmarks`): the `BMS`/`BME`/`XRF` sentinels are spliced into the **run text** rather than emitted by a run builder, so they ride every odf-kit path (cells, custom paragraphs, lists) with no per-emitter change; one post-pass rewrites them to `<text:bookmark-start/>`/`<text:bookmark-end/>`/`<text:bookmark-ref>`. DOCX needs no pass at all — the `docx` package has `Bookmark`, `SimpleField` (`REF`/`PAGEREF`) and `InternalHyperlink`. See `docs/architecture/formatting.md`.
- **Captions** (`replaceSequenceFields`/`applySequenceFields`): a `SEQ` sentinel becomes a `<text:sequence>` carrying LibreOffice's own `text:formula="ooow:<name>+1"` (what makes its reader recount on load) plus a `<text:sequence-decl>` per counter used; DOCX gets a `SimpleField` `SEQ Figure \* ARABIC`. Both keep the editor's resolved rank as the field's cached result.
- **Tracked changes** (`replaceRevisions`/`applyRevisions`): ODF splits the pair. An insertion keeps its text inline and is bracketed by `TCI` sentinels → `<text:change-start/>`/`<text:change-end/>`; a deletion's text is **cut out of the run** and left as one `TCD` → `<text:change/>`, the text itself going into the `<text:tracked-changes>` registry `applyRevisions` builds right after `<office:text>` (a `<text:deletion>` holds the deleted paragraphs, a `<text:insertion>` only its `<office:change-info>`). The registry's `<dc:creator>`/`<dc:date>` need `ensureDcNamespace` — odf-kit declares no `dc:` prefix in content.xml. DOCX needs no string pass: `InsertedTextRun`/`DeletedTextRun` from the `docx` package emit `w:ins`/`w:del` + `w:delText`, with `docRevisionId` numbering them per document as it does comments.
- **Tracked changes** (`replaceRevisions`/`applyRevisions`): ODF splits the pair. An insertion keeps its text inline and is bracketed by `TCI` sentinels → `<text:change-start/>`/`<text:change-end/>`; a deletion's text is **cut out of the run** and left as one `TCD` → `<text:change/>`, the text itself going into the `<text:tracked-changes>` registry `applyRevisions` builds right after `<office:text>` (a `<text:deletion>` holds the deleted paragraphs, a `<text:insertion>` only its `<office:change-info>`). The registry's `<dc:creator>`/`<dc:date>` need `ensureDcNamespace` — odf-kit declares no `dc:` prefix in content.xml. The registry also carries **whether recording goes on** (`text:track-changes`), so it is written for that alone when a document records but holds no change yet — LibreOffice writes the same empty element (probed). Word keeps that flag in settings.xml instead, which the `docx` package exposes as `features.trackRevisions` (its schema quote names the element). DOCX needs no string pass: `InsertedTextRun`/`DeletedTextRun` from the `docx` package emit `w:ins`/`w:del` + `w:delText`, with `docRevisionId` numbering them per document as it does comments.
- **Bibliography** (`replaceBibEntries`/`applyBibEntries`, `applyBibliographyDocx`): a `BIB` sentinel becomes a `<text:bibliography-mark>` whose attributes are the whole source record and which **wraps** the text the citation shows (unlike an index mark, which is a point). The `<text:bibliography>` index writes an entry template per type actually cited, spelling out the same "key: author, title, year" the node view prints — LibreOffice regenerates the rows from it, so a template that said anything else would rewrite them. DOCX cites by tag (`CITATION "key"`) and keeps the sources in a **custom-XML part** the `docx` package knows nothing about, so the post-pack pass mints all four pieces itself: `customXml/item1.xml` (`b:Sources`), its `itemProps1.xml`, both relationships and the properties part's content type. (Probed: LibreOffice's own DOCX export writes the `CITATION` field but no sources at all.)
- **Ruby** (`replaceRuby`/`applyRuby`, `applyRubyDocx`): an `RBY` sentinel becomes a `<text:ruby>` holding both halves plus one minted `style:family="ruby"` automatic style (centred, above — probed: LibreOffice writes that style straight back out, adding only its own `loext:` mirror of the position). Word's is a `<w:ruby>` run the `docx` package cannot emit, so a post-pack pass swaps the sentinel run for it; the `w:rubyPr` sizes are half-points of the 12pt body default, which is what LibreOffice's own export writes.
- **Cell formulas** (`applyCellFormulas`, `formulaCellContent`): ODF puts the formula on the **cell**, so the pass walks `<table:table-cell` in the same document order `applyCellBlocks` does and writes `table:formula="ooow:…"` + the cached `office:value`. The `ooow:` is a namespace prefix, so `ensureOoowNamespace` declares it — undeclared, LibreOffice reads it as part of the formula. Word instead keeps a field *inside* the cell, so the DOCX side swaps the cell's first paragraph for one `SimpleField` — and the cell's **number format** rides that field's `\#` switch (`\@` for a date), where ODF puts it on the cell style (a minted data style plus a copy of the shared cell style that references it). The data style names the document's language, or LibreOffice prints its own locale's separators and currency symbol. See `docs/architecture/tables.md`.
+6 -1
View File
@@ -1907,6 +1907,7 @@ export async function buildDocx(
pageNumbering: PageNumbering = DEFAULT_PAGE_NUMBERING,
decor: PageDecor = EMPTY_PAGE_DECOR,
lineNumbering: LineNumbering = DEFAULT_LINE_NUMBERING,
recordChanges = false,
): Promise<Uint8Array> {
docLangTag = localeTag(language ? language.language : 'en');
exportSheet = styles;
@@ -2028,7 +2029,11 @@ export async function buildDocx(
// Word's Layout > Hyphenation (w:autoHyphenation in settings.xml).
...(hyphenate ? { hyphenation: { autoHyphenation: true } } : {}),
...(differentOddEven ? { evenAndOddHeaderAndFooters: true } : {}),
...(hasToc ? { features: { updateFields: true } } : {}),
// w:updateFields and w:trackRevisions are both settings.xml flags the package
// takes under one key, so a document wanting both must ask for them together.
...(hasToc || recordChanges
? { features: { ...(hasToc ? { updateFields: true } : {}), ...(recordChanges ? { trackRevisions: true } : {}) } }
: {}),
styles: buildStyles(styles, usedStyleNames(docJson, styles), language, usedTableStyles(docJson, styles)),
numbering: { config: num.config },
...(Object.keys(notesByClass.footnote).length ? { footnotes: notesByClass.footnote } : {}),
+11 -6
View File
@@ -613,9 +613,11 @@ function replaceRevisions(node: TiptapNode, out: Map<string, RevisionExport>): T
return { ...node, content };
}
// Resolve the revision sentinels and prepend the registry the body points into.
function applyRevisions(odtBytes: Uint8Array, list: Map<string, RevisionExport>): Uint8Array {
if (!list.size) return odtBytes;
// Resolve the revision sentinels and prepend the registry the body points into. The
// registry also carries whether the document goes on recording — LibreOffice writes it
// with no changes in it for exactly that (probed).
function applyRevisions(odtBytes: Uint8Array, list: Map<string, RevisionExport>, recording: boolean): Uint8Array {
if (!list.size && !recording) return odtBytes;
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
if (!contentBytes) return odtBytes;
@@ -648,7 +650,10 @@ function applyRevisions(odtBytes: Uint8Array, list: Map<string, RevisionExport>)
: `<text:insertion>${info}</text:insertion>`;
return `<text:changed-region xml:id="${ct}" text:id="${ct}">${body}</text:changed-region>`;
}).join('');
content = content.replace(/(<office:text\b[^>]*>)/, `$1<text:tracked-changes>${regions}</text:tracked-changes>`);
content = content.replace(
/(<office:text\b[^>]*>)/,
`$1<text:tracked-changes text:track-changes="${recording}">${regions}</text:tracked-changes>`,
);
content = ensureDcNamespace(content);
files['content.xml'] = strToU8(content);
@@ -4363,7 +4368,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, props: DocProperties = EMPTY_DOC_PROPERTIES, hyphenate = false, pageNumbering: PageNumbering = DEFAULT_PAGE_NUMBERING, decor: PageDecor = EMPTY_PAGE_DECOR, lineNumbering: LineNumbering = DEFAULT_LINE_NUMBERING): 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, hyphenate = false, pageNumbering: PageNumbering = DEFAULT_PAGE_NUMBERING, decor: PageDecor = EMPTY_PAGE_DECOR, lineNumbering: LineNumbering = DEFAULT_LINE_NUMBERING, recordChanges = false): 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).
@@ -4588,7 +4593,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const withBreaks = applyInlineSentinels(applyTabLeaders(cleaned));
const withImages = applyImages(withBreaks, images);
const withDateFields = applyDateTimeFields(withImages, dateFields, language ?? null);
const withSequences = applyRuby(applyBibEntries(applyIndexEntries(applyRevisions(applySequenceFields(withDateFields, seqFields), revisionList), indexMarks), bibMarks), rubies);
const withSequences = applyRuby(applyBibEntries(applyIndexEntries(applyRevisions(applySequenceFields(withDateFields, seqFields), revisionList, recordChanges), indexMarks), bibMarks), rubies);
const withFormulas = applyFormulas(withSequences, formulas);
const withTextBoxes = applyTextBoxes(withFormulas, textBoxes);
const withColumns = applyColumns(withTextBoxes, columns);
+1 -1
View File
@@ -35,7 +35,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(
- **Vertical text in a box** is the frame style's writing mode in ODF (its *paragraph* properties, both top-to-bottom modes) and `w:bodyPr vert` in DOCX (`vert`/`eaVert`/`mongolianVert`/`wordArtVert`; `vert270` reads bottom-to-top, which the browser cannot lay out, so it is not one). VML says the same as `layout-flow:vertical` on the textbox.
- **Ruby**: `text:ruby` and `w:ruby` are one atom here (`ruby.ts`), so only the two texts are read — the base out of `text:ruby-base` / `w:rubyBase`, the reading out of `text:ruby-text` / `w:rt`. A file's own alignment and position are dropped; both products' defaults are what the exports write.
- **Bibliography**: a `text:bibliography-mark`'s every `text:` attribute but the identifier and the type is a source field, whatever the file names it, so nothing is lost on a format we have no UI for. Word's `CITATION` names a tag only — the record comes from `customXml/item*.xml` (each is probed for a `b:Sources` root), and a tag no source declares still cites with the field's cached text. `BIBLIOGRAPHY` and `text:bibliography` both become the index node.
- **Tracked changes**: ODF points at a registry, so `odfRevisions(body)` reads `<text:tracked-changes>` first and the body walk resolves each `text:change-start`/`-end` and `text:change` against it (the open insertions live in `Ctx` — a range may close in a later paragraph). A `text:change` is a deletion whose text is *only* in the registry: it is emitted there and then, marked. Word needs no registry — `w:ins`/`w:del` wrap their runs, whose text sits in `w:delText` rather than `w:t` for a deletion.
- **Tracked changes**: ODF points at a registry, so `odfRevisions(body)` reads `<text:tracked-changes>` first and the body walk resolves each `text:change-start`/`-end` and `text:change` against it (the open insertions live in `Ctx` — a range may close in a later paragraph). A `text:change` is a deletion whose text is *only* in the registry: it is emitted there and then, marked. Word needs no registry — `w:ins`/`w:del` wrap their runs, whose text sits in `w:delText` rather than `w:t` for a deletion. Whether the document goes on **recording** is read too (`recordChanges`): the registry's `text:track-changes`, which ODF defaults to true where the element carries none, and Word's `w:trackRevisions` on/off in settings.xml.
- **Footnotes and endnotes** round-trip: `text:note` (ODF) and `word/footnotes.xml`/`endnotes.xml` (DOCX) become a `noteRef` anchor plus a `note` in the document-final `noteSection`, and the numbering configuration becomes `NoteSettings`. See `docs/architecture/notes.md`.
- **Graceful degradation** (collected as `warnings`, surfaced once): comments dropped, hyperlinks flattened to text, nested tables flattened to paragraphs, non-`Pictures` drawings dropped, unreadable pictures skipped (each with a warning). Page size is normalized to A4. Floating/wrapped images are imported as left/right/top-bottom wrap (free x/y positions collapse to the nearest side; see `image.ts`).
+14
View File
@@ -255,6 +255,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
decor: docxPageDecor(docDoc, finalSectPr, files),
lineNumbering: docxLineNumbering(finalSectPr),
hyphenate: docAutoHyphenation(files),
recordChanges: docRecordsChanges(files),
pageNumbering: docxPageNumbering(sectPr),
orientation: docPaper.orientation,
format: docPaper.format,
@@ -2523,6 +2524,19 @@ function tblIndIsToText(files: Record<string, Uint8Array>): boolean {
return true;
}
// Whether the document records revisions — settings.xml, like the two below (probed:
// LibreOffice writes and reads exactly this element for its own Record Changes).
function docRecordsChanges(files: Record<string, Uint8Array>): boolean {
const bytes = files['word/settings.xml'];
if (!bytes) return false;
try {
const el = parseXml(strFromU8(bytes)).getElementsByTagNameNS(W, 'trackRevisions')[0];
return !!el && onOff(el);
} catch {
return false;
}
}
function docHasMirrorMargins(files: Record<string, Uint8Array>): boolean {
const bytes = files['word/settings.xml'];
if (!bytes) return false;
+10
View File
@@ -68,6 +68,8 @@ export interface OdtImportResult {
lineNumbering: LineNumbering;
// Automatic hyphenation (ODF fo:hyphenate on the base style, Word w:autoHyphenation).
hyphenate: boolean;
// Whether the document records revisions (ODF text:track-changes, Word w:trackRevisions).
recordChanges: boolean;
// How the page-number field counts (ODF style:num-format + style:page-number, Word w:pgNumType).
pageNumbering: PageNumbering;
// The grid every tab past the last custom stop falls on; the format's own fallback
@@ -793,6 +795,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
decor: resolver.pageDecor(),
lineNumbering: resolver.lineNumbering(),
hyphenate: resolver.documentHyphenation(),
recordChanges: odfRecordChanges(body),
pageNumbering: { format: resolver.pageNumberFormat(), start: odfPageNumberStart(resolver, body) },
tabIntervalCm: resolver.defaultTabInterval(),
spacingModel: odfSpacingModel(files),
@@ -1757,6 +1760,13 @@ function convertDateTimeField(e: Element, ctx: Ctx): Node | null {
return { type: 'dateTimeField', attrs: { kind, format, fixed, value: fieldValue(kind, raw) } };
}
// Whether the document records revisions: the registry's own flag, which ODF defaults
// to true where the element is there without it. No registry at all records nothing.
function odfRecordChanges(body: Element): boolean {
const registry = body.getElementsByTagNameNS(NS.text, 'tracked-changes')[0];
return !!registry && registry.getAttributeNS(NS.text, 'track-changes') !== 'false';
}
// The <text:tracked-changes> registry: one entry per change id. A deletion carries the
// text it removed (the body has only a point marker), an insertion only who and when.
function odfRevisions(body: Element): Map<string, { kind: 'insertion' | 'deletion'; author: string; date: string; text: string }> {
+1 -1
View File
@@ -17,7 +17,7 @@
- **`autoText.ts`** / **`.svelte.ts`** — the AutoText library (`edentext-autotext`, the key removed when empty): name, shortcut and the entry's HTML. App-level, not document-level, so no exporter looks at it.
- **`tableOptions.svelte.ts`** — table number recognition (`edentext-number-recognition`, the key removed when off, as it is by default in LibreOffice). A reactive singleton like `trackChanges.svelte.ts`: the extension reads it on every transaction, both table surfaces flip it. It also holds the **number locale** — the document's language, which is what LibreOffice reads and writes a cell's number in — pushed by `App.svelte`'s language effect; the exporters take that locale from their own `language` argument instead, so they stay store-free. See `docs/architecture/tables.md`.
- **`pageDecor.ts`** — the page's own decoration (`edentext-page-decor`, the key removed when all three are off): background colour, a border around the text area at `paddingCm` from it, and a text watermark (LibreOffice's Format ▸ Watermark, field for field). All three ride the ODF page layout — the watermark as a `fontwork-plain-text` `<draw:custom-shape>` named `PowerPlusWaterMarkObject` in the **master page's header**, the only place LibreOffice reads one from (probed: hung off the master page directly it is dropped, and an undeclared `xmlns:draw` in styles.xml drops it too). Word keeps the background on `w:document` (plus `w:displayBackgroundShape`), the border in `w:sectPr` **after `w:pgMar`** — the child order is fixed, and ahead of the header references Word's reader loses those — and the watermark as the VML `_x0000_t136` shape, whose definition has to travel or LibreOffice cannot resolve it. `PageDecorLayer.svelte` draws border and watermark per page; the background is a `--color-page-bg` override on `.paper`.
- **`trackChanges.svelte.ts`** — whether the editor records revisions (`edentext-record-changes`, the key removed when off, as it is by default in both word processors). A reactive singleton because the extension reads it on every transaction while the Review tab flips it; the recorded changes themselves live in the document, on the marks.
- **`trackChanges.svelte.ts`** — whether the **document** records revisions (`edentext-record-changes`, the key removed when off, as it is by default in both word processors). It travels with the file — ODF `text:track-changes` on the change registry, Word `w:trackRevisions` in settings.xml (both probed against soffice) — so an import adopts the file's flag and a new document starts off; only the open document's state lives under the key. A reactive singleton because the extension reads it on every transaction while the Review tab flips it; the recorded changes themselves live in the document, on the marks.
- **`lineNumbering.ts`** — numbers in the left margin (`edentext-line-numbering`, the key removed when off, as it is by default in both word processors). ODF keeps one `<text:linenumbering-configuration>` in `office:styles`, Word a `<w:lnNumType>` per `w:sectPr` (probed). `LineNumberLayer.svelte` measures the lines: CSS exposes no line boxes, so a Range per block is taken and its distinct client-rect tops are the lines.
- **`pageOrientation.ts`** — `'portrait' | 'landscape'`. `applyOrientationVars` sets `--user-page-{width,height}`; landscape swaps the A4 dimensions (matching odf-kit's automatic swap).
+17
View File
@@ -408,4 +408,21 @@ describe.skipIf(!SOFFICE)('LibreOffice round-trip (needs soffice on PATH)', () =
=== 'KAF01: Kafka, Franz, Der Process, 1925 | MEY99: Meyer, Anna, Zur Sache, 1999',
index?.attrs?.entries);
});
it('survives a `soffice` re-save of the record-changes flag', { timeout: 180000 }, async () => {
const doc: N = { type: 'doc', content: [P(null, T('Ein Satz.'))] };
const bytes = await buildOdt(doc, margins, 'portrait', undefined, null, 'A4', undefined,
undefined, 'add', false, undefined, undefined, false, undefined, undefined, undefined, true);
mkdirSync('/tmp/lo-rt', { recursive: true });
writeFileSync('/tmp/lo-rt/rec.odt', bytes);
execSync('soffice --headless --convert-to odt --outdir /tmp/lo-rt/recout /tmp/lo-rt/rec.odt', { stdio: 'pipe', timeout: 120000 });
const resaved = new Uint8Array(readFileSync('/tmp/lo-rt/recout/rec.odt'));
const xml = strFromU8(unzipSync(resaved)['content.xml']);
// LibreOffice keeps the empty registry for the flag alone, which is what makes it
// the document's setting rather than the editor's.
check('LO: the flag comes back', /<text:tracked-changes[^>]*text:track-changes="true"/.test(xml),
xml.match(/<text:tracked-changes[^>]*>/g));
check('LO: and the importer reads it', importOdt(resaved).recordChanges === true);
});
});
+4 -1
View File
@@ -2171,7 +2171,10 @@ describe('Leg 23: recorded revisions', () => {
it('ODT: the registry plus change-start/-end and a deletion marker, and back', async () => {
const bytes = await buildOdt(doc, margins, 'portrait');
const content = strFromU8(unzipSync(bytes)['content.xml']);
check('registry emitted', /<text:tracked-changes><text:changed-region/.test(content), content.slice(0, 200));
// The registry carries whether recording goes on; this document has changes but
// records no more, which is the pair LibreOffice writes for a stopped review.
check('registry emitted', /<text:tracked-changes text:track-changes="false"><text:changed-region/.test(content),
content.slice(0, 200));
check('insertion bracketed', /<text:change-start text:change-id="ct1"\/>NEUER TEXT <text:change-end/.test(content),
content.match(/<text:p[^>]*>[\s\S]*?<\/text:p>/g)?.slice(-1));
// ODF keeps a deletion's text in the registry, never inline.
@@ -0,0 +1,49 @@
// Recording revisions is a property of the document, not of the editor: ODF keeps it on
// the change registry (text:track-changes), Word in settings.xml (w:trackRevisions) —
// both probed against soffice, which writes and reads exactly these two.
import { describe, it, expect } from 'vitest';
import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate';
import { buildOdt } from '../../src/lib/export/odt';
import { buildDocx } from '../../src/lib/export/docx';
import { importOdt } from '../../src/lib/import/odt';
import { importDocx } from '../../src/lib/import/docx';
import { DEFAULT_MARGINS } from '../../src/lib/storage/pageMargins';
type N = any;
const doc: N = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Ein Satz.' }] }] };
const m = DEFAULT_MARGINS;
// Everything up to the flag is the exporters' own default, so only the last one varies.
const args = (on: boolean) =>
[m, 'portrait', undefined, null, 'A4', undefined, undefined, 'add', false, undefined, undefined,
false, undefined, undefined, undefined, on] as const;
describe('recording revisions survives both round trips', () => {
it('ODT: the registry carries the flag, with no change in it', async () => {
const on = await buildOdt(doc, ...args(true));
const content = strFromU8(unzipSync(on)['content.xml']);
expect(content).toContain('<text:tracked-changes text:track-changes="true">');
expect(importOdt(on).recordChanges).toBe(true);
const off = await buildOdt(doc, ...args(false));
expect(strFromU8(unzipSync(off)['content.xml'])).not.toContain('text:tracked-changes');
expect(importOdt(off).recordChanges).toBe(false);
});
it('DOCX: w:trackRevisions in settings.xml', async () => {
const on = await buildDocx(doc, ...args(true));
expect(strFromU8(unzipSync(on)['word/settings.xml'])).toContain('<w:trackRevisions');
expect(importDocx(on).recordChanges).toBe(true);
const off = await buildDocx(doc, ...args(false));
expect(strFromU8(unzipSync(off)['word/settings.xml'])).not.toContain('<w:trackRevisions');
expect(importDocx(off).recordChanges).toBe(false);
});
it('a registry with no flag records, as ODF defaults it to true', async () => {
// What another producer may write: the element is there, the attribute is not.
const files = unzipSync(await buildOdt(doc, ...args(true)));
files['content.xml'] = strToU8(strFromU8(files['content.xml']).replace(' text:track-changes="true"', ''));
expect(importOdt(zipSync(files)).recordChanges).toBe(true);
});
});