diff --git a/CHANGELOG.md b/CHANGELOG.md index 8679890..ef98633 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/App.svelte b/src/App.svelte index c17e14a..546da35 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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[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[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[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[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; diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index d56acaa..5512c0d 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -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 ``/``/``. 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 `` carrying LibreOffice's own `text:formula="ooow:+1"` (what makes its reader recount on load) plus a `` 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 → ``/``; a deletion's text is **cut out of the run** and left as one `TCD` → ``, the text itself going into the `` registry `applyRevisions` builds right after `` (a `` holds the deleted paragraphs, a `` only its ``). The registry's ``/`` 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 → ``/``; a deletion's text is **cut out of the run** and left as one `TCD` → ``, the text itself going into the `` registry `applyRevisions` builds right after `` (a `` holds the deleted paragraphs, a `` only its ``). The registry's ``/`` 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 `` whose attributes are the whole source record and which **wraps** the text the citation shows (unlike an index mark, which is a point). The `` 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 `` 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 `` 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 ` { 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 } : {}), diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 6a1ee9b..bb169ca 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -613,9 +613,11 @@ function replaceRevisions(node: TiptapNode, out: Map): T return { ...node, content }; } -// Resolve the revision sentinels and prepend the registry the body points into. -function applyRevisions(odtBytes: Uint8Array, list: Map): 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, 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) : `${info}`; return `${body}`; }).join(''); - content = content.replace(/(]*>)/, `$1${regions}`); + content = content.replace( + /(]*>)/, + `$1${regions}`, + ); 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 { +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 { // 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); diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index 784d2fc..8514396 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -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 `` 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 `` 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`). diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 01e0739..ec5ba36 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -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): 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): 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): boolean { const bytes = files['word/settings.xml']; if (!bytes) return false; diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 9e0d35a..b601b55 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -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 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 { diff --git a/src/lib/storage/CLAUDE.md b/src/lib/storage/CLAUDE.md index 52fb4ff..a65bb2a 100644 --- a/src/lib/storage/CLAUDE.md +++ b/src/lib/storage/CLAUDE.md @@ -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` `` 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 `` in `office:styles`, Word a `` 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). diff --git a/tests/lo-roundtrip.test.ts b/tests/lo-roundtrip.test.ts index 6e62e68..587497d 100644 --- a/tests/lo-roundtrip.test.ts +++ b/tests/lo-roundtrip.test.ts @@ -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:track-changes="true"/.test(xml), + xml.match(/]*>/g)); + check('LO: and the importer reads it', importOdt(resaved).recordChanges === true); + }); }); diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 34ec48d..da7c392 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -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', /NEUER TEXT ]*>[\s\S]*?<\/text:p>/g)?.slice(-1)); // ODF keeps a deletion's text in the registry, never inline. diff --git a/tests/unit/record-changes-roundtrip.test.ts b/tests/unit/record-changes-roundtrip.test.ts new file mode 100644 index 0000000..c14e549 --- /dev/null +++ b/tests/unit/record-changes-roundtrip.test.ts @@ -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(''); + 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(' { + // 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); + }); +});