From 96d827b2c70383a8334e8fbb1105631f0cf10378 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Fri, 14 Aug 2026 06:50:25 +0200 Subject: [PATCH] feat: page background, page border and watermark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LibreOffice's Format ▸ Page Style ▸ Area/Borders and its Format ▸ Watermark, all three round-tripping. Probed placements that are not guessable: ODF reads a watermark only out of the master page's header (and only with xmlns:draw declared), Word's w:sectPr wants pgBorders after pgMar or it loses the header references, and the VML shapetype has to travel or LibreOffice cannot resolve it. On screen the watermark is an SVG textLength, since a fontwork shape stretches its text to its box rather than setting a font size. --- CHANGELOG.md | 2 +- src/App.svelte | 13 +- src/lib/components/CLAUDE.md | 8 + src/lib/components/Editor.svelte | 9 +- src/lib/components/PageDecorDialog.svelte | 188 ++++++++++++++++++ src/lib/components/PageDecorLayer.svelte | 111 +++++++++++ src/lib/components/ribbon/Ribbon.svelte | 5 +- src/lib/components/ribbon/icons.ts | 1 + .../components/ribbon/tabs/LayoutTab.svelte | 16 ++ src/lib/export/docx.ts | 114 ++++++++++- src/lib/export/odt.ts | 109 +++++++++- src/lib/i18n/locales/de.ts | 17 ++ src/lib/i18n/locales/en.ts | 17 ++ src/lib/import/docx.ts | 57 +++++- src/lib/import/odt.ts | 9 +- src/lib/import/styleResolver.ts | 47 +++++ src/lib/storage/CLAUDE.md | 1 + src/lib/storage/pageDecor.ts | 96 +++++++++ tests/roundtrip.test.ts | 62 ++++++ 19 files changed, 867 insertions(+), 15 deletions(-) create mode 100644 src/lib/components/PageDecorDialog.svelte create mode 100644 src/lib/components/PageDecorLayer.svelte create mode 100644 src/lib/storage/pageDecor.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 03a62d1..808044c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,6 +116,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 - List of figures and list of tables (References ▸ Table of contents ▸ the two caption indexes): the same generated index the table of contents is, over the captions of one category instead of the headings — single-level, with live page numbers and leader dots. Round-trips as ODF `text:illustration-index` / `text:table-index` (found by the counter's name, as LibreOffice finds them) and Word's `TOC \c "Figure"` field +- Page design (Layout ▸ Page design): a page background colour, a border around the text area at a settable distance from it, and a text watermark with its own colour, angle and transparency — LibreOffice's Format ▸ Page Style ▸ Area/Borders and its Format ▸ Watermark. All three round-trip: ODF keeps them on the page layout and the master page's header, Word on `w:document` / `w:sectPr` / a VML shape in the header part - Captions (References ▸ Caption, LibreOffice's Insert ▸ Caption): a caption paragraph in the `Caption` style with a running number that counts itself — one counter per category (figure / table), in document order, renumbering as the document is edited. Round-trips as an ODF `` (with LibreOffice's own `ooow:Illustration+1` formula) and a Word `SEQ Figure \* ARABIC` field - 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` @@ -145,7 +146,6 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-08. - Linked / chained text frames - Find & Replace by formatting or by style (the regular-expression half is implemented) - Save as template (`.ott` / `.dotx`), multi-document management, recent-files list -- Watermark; page background and page border - Thesaurus, grammar check, word completion, AutoText / building blocks - Vertical writing modes (ODF `tb-rl`) and Asian typography (ruby) - Password-protected ODT/DOCX; digital signatures diff --git a/src/App.svelte b/src/App.svelte index 2e888c9..83f57e6 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -38,6 +38,7 @@ import { loadDocProperties, saveDocProperties, EMPTY_DOC_PROPERTIES, type DocProperties } from './lib/storage/docProperties'; import { loadHyphenation, saveHyphenation } from './lib/storage/hyphenation'; import { loadPageNumbering, savePageNumbering, DEFAULT_PAGE_NUMBERING, type PageNumbering } from './lib/storage/pageNumbering'; + import { loadPageDecor, savePageDecor, EMPTY_PAGE_DECOR, type PageDecor } from './lib/storage/pageDecor'; import { loadDocumentLanguage, saveDocumentLanguage, odfFromLanguage, type DocumentLanguage } from './lib/storage/documentLanguage'; import { spellController } from './lib/spell/controller'; import LanguagePicker from './lib/components/LanguagePicker.svelte'; @@ -158,6 +159,7 @@ let docPropsOpen = $state(false); let hyphenate = $state(loadHyphenation()); let pageNumbering: PageNumbering = $state(loadPageNumbering()); + let pageDecor: PageDecor = $state(loadPageDecor()); let autoCorrectOpen = $state(false); let commentsOpen = $state(false); @@ -228,6 +230,7 @@ $effect(() => { savePageNumbering(pageNumbering); + savePageDecor(pageDecor); }); $effect(() => { @@ -506,6 +509,7 @@ documentName = ''; hyphenate = false; pageNumbering = { ...DEFAULT_PAGE_NUMBERING }; + pageDecor = { ...EMPTY_PAGE_DECOR }; docProps = { ...EMPTY_DOC_PROPERTIES }; saveDocProperties(docProps); fileHandle = null; @@ -568,6 +572,7 @@ pageRtl = result.rtl; hyphenate = result.hyphenate; pageNumbering = result.pageNumbering; + pageDecor = result.decor; // 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; @@ -665,7 +670,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); + const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor); fileHandle = await saveOdt(bytes, suggestedFilename(json), fileHandle); } catch (err) { if ((err as DOMException)?.name === 'AbortError') return; @@ -681,7 +686,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); + const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor); fileHandle = await saveAsOdt(bytes, suggestedFilename(json)); } catch (err) { if ((err as DOMException)?.name === 'AbortError') return; @@ -699,7 +704,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); + const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor); await saveAsDocx(bytes, suggestedFilenameDocx(json)); } catch (err) { if ((err as DOMException)?.name === 'AbortError') return; @@ -915,6 +920,7 @@ bind:pageFormat bind:hyphenate bind:pageNumbering + bind:pageDecor {hfActive} onManageStyles={openStyleManager} onManageTableStyles={() => openStyleManager('table')} @@ -1212,6 +1218,7 @@ {hyphenate} {documentLanguage} {pageNumbering} + {pageDecor} bind:extraHfSections {zoom} onZoom={setZoom} diff --git a/src/lib/components/CLAUDE.md b/src/lib/components/CLAUDE.md index 3151332..13811c9 100644 --- a/src/lib/components/CLAUDE.md +++ b/src/lib/components/CLAUDE.md @@ -74,6 +74,14 @@ range, edit / resolve / remove in place. `App.svelte` owns the New-comment promp both the Review tab and the context menu (`OPEN_COMMENT_EVENT`) fire; the author comes from the document properties. +## Page decoration (`PageDecorLayer.svelte`, `storage/pageDecor.ts`) + +Background, border and watermark, all page-level. The layer sits inside the scaled +`.paper` at `z-index: -1` like `.hf-bg-layer`, drawing one border box and one watermark +per page in unscaled document px. The watermark is an `` because a +fontwork shape **stretches** its text to its box instead of setting it at a size — a font +size alone makes a short word far too small and a long one overflow the page. + ## Debug tooling (dev only) In dev builds a **Debug** button (`App.svelte`) downloads a JSON snapshot combining `getPageBreakDebug(view)` (leaves, placements, rendered spacers, table-break bands, live overlay geometry), `getTableCellDebug(view)` (per cell: the `verticalAlign` attr beside the computed value, cell/row height, the gap above and below the content, and every block's margins/`--space-before` — cell alignment reads as broken whenever the content fills the box, so the numbers that decide that travel with it), the style sheet + spacing model, and `getColorDebug(editor)` (selection marks, text runs, document colors, DOM spans). diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 5347f1f..df8eb2e 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -16,6 +16,8 @@ import { NodeSelection, TextSelection } from '@tiptap/pm/state'; import ContextMenu from './ContextMenu.svelte'; import HeaderFooterLayer from './HeaderFooterLayer.svelte'; +import PageDecorLayer from './PageDecorLayer.svelte'; +import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor'; import Ruler from './Ruler.svelte'; import { saveDocument, loadDocument, markDocumentLoaded } from '../storage/autosave'; import { applyMarginVars, cmToPx, DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins'; @@ -44,7 +46,7 @@ let { editor = $bindable(), tick = $bindable(0), currentPage = $bindable(1), numPages = $bindable(1), zoom = 100, onZoom, showFormattingMarks = false, showRuler = true, pageMargins = DEFAULT_MARGINS, orientation = 'portrait', - pageFormat = 'A4', tabIntervalCm = DEFAULT_TAB_INTERVAL_CM, spacingModel = 'add', documentEpoch = 0, pageRtl = false, hyphenate = false, documentLanguage = 'en', pageNumbering = DEFAULT_PAGE_NUMBERING, + pageFormat = 'A4', tabIntervalCm = DEFAULT_TAB_INTERVAL_CM, spacingModel = 'add', documentEpoch = 0, pageRtl = false, hyphenate = false, documentLanguage = 'en', pageNumbering = DEFAULT_PAGE_NUMBERING, pageDecor = EMPTY_PAGE_DECOR, headerDoc = $bindable(null), footerDoc = $bindable(null), hfDistances = DEFAULT_HF_DISTANCES, headerFirstDoc = $bindable(null), footerFirstDoc = $bindable(null), differentFirstPage = false, headerEvenDoc = $bindable(null), footerEvenDoc = $bindable(null), differentOddEven = false, @@ -62,6 +64,8 @@ hyphenate?: boolean; documentLanguage?: string; /** How the page-number field counts (format + start value). */ pageNumbering?: PageNumbering; + /** Page background, page border and watermark. */ + pageDecor?: PageDecor; headerDoc?: HfDoc; footerDoc?: HfDoc; hfDistances?: HfDistances; headerFirstDoc?: HfDoc; footerFirstDoc?: HfDoc; differentFirstPage?: boolean; headerEvenDoc?: HfDoc; footerEvenDoc?: HfDoc; differentOddEven?: boolean; @@ -984,7 +988,7 @@
-
+
@@ -1004,6 +1008,7 @@ {/each}
{/if} + + import { + DEFAULT_PAGE_BORDER, DEFAULT_WATERMARK, EMPTY_PAGE_DECOR, + type PageBorder, type PageDecor, type Watermark, + } from '../storage/pageDecor'; + import { t } from '../i18n/i18n.svelte'; + + // LibreOffice's Format ▸ Page Style ▸ Area and Borders plus its Format ▸ Watermark, + // in one panel — all three decorate the page and all three ride the page layout. + let { open = $bindable(false), decor = $bindable(EMPTY_PAGE_DECOR) }: { + open?: boolean; + decor?: PageDecor; + } = $props(); + + let dialogEl = $state(null); + + $effect(() => { + const el = dialogEl; + if (!el) return; + if (open && !el.open) el.showModal(); + else if (!open && el.open) el.close(); + }); + + const num = (e: Event) => parseFloat((e.currentTarget as HTMLInputElement).value.replace(',', '.')); + + function patchBorder(next: Partial) { + decor = { ...decor, border: { ...(decor.border ?? DEFAULT_PAGE_BORDER), ...next } }; + } + + function patchWatermark(next: Partial) { + decor = { ...decor, watermark: { ...(decor.watermark ?? DEFAULT_WATERMARK), ...next } }; + } + + + (open = false)} + onclick={(e) => e.target === dialogEl && (open = false)} + aria-label={t().pageDecor.title} +> +
+

{t().pageDecor.title}

+ +
+ {t().pageDecor.background} + +
+ +
+ {t().pageDecor.border} + + + +
+ +
+ {t().pageDecor.watermark} + + + + +
+ +
+ + + +
+
+
+ + diff --git a/src/lib/components/PageDecorLayer.svelte b/src/lib/components/PageDecorLayer.svelte new file mode 100644 index 0000000..4858169 --- /dev/null +++ b/src/lib/components/PageDecorLayer.svelte @@ -0,0 +1,111 @@ + + + + + diff --git a/src/lib/components/ribbon/Ribbon.svelte b/src/lib/components/ribbon/Ribbon.svelte index d7f1dc6..59a10e2 100644 --- a/src/lib/components/ribbon/Ribbon.svelte +++ b/src/lib/components/ribbon/Ribbon.svelte @@ -28,6 +28,7 @@ import type { DocumentLanguage } from '../../storage/documentLanguage'; import { DEFAULT_TAB_INTERVAL_CM } from '../../storage/tabInterval'; import { DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../../storage/pageNumbering'; + import { EMPTY_PAGE_DECOR, type PageDecor } from '../../storage/pageDecor'; let { editor, @@ -47,6 +48,7 @@ pageFormat = $bindable('A4'), hyphenate = $bindable(false), pageNumbering = $bindable(DEFAULT_PAGE_NUMBERING), + pageDecor = $bindable(EMPTY_PAGE_DECOR), hfActive = null, onManageStyles, onManageTableStyles, @@ -77,6 +79,7 @@ pageFormat?: PageFormat; hyphenate?: boolean; pageNumbering?: PageNumbering; + pageDecor?: PageDecor; hfActive?: HfZone | null; onManageStyles?: (family: StyleFamily) => void; onManageTableStyles?: (family: StyleFamily) => void; @@ -297,7 +300,7 @@ {:else if tab === 'insert'} {:else if tab === 'layout'} - (paragraphDialogOpen = true)} /> + (paragraphDialogOpen = true)} /> {:else if tab === 'references'} {:else if tab === 'review'} diff --git a/src/lib/components/ribbon/icons.ts b/src/lib/components/ribbon/icons.ts index 8a8cbe5..f58bb6f 100644 --- a/src/lib/components/ribbon/icons.ts +++ b/src/lib/components/ribbon/icons.ts @@ -78,6 +78,7 @@ export const STROKED = { toc: ['M2 3h6M2 6.5h5M2 10h6M2 13.5h4', 'M11 3h3M10 6.5h4M11 10h3M10 13.5h4'], tocLevels: ['M2.5 3.5h11M4.5 7h9M6.5 10.5h7M8.5 14h5'], caption: ['M2 2.5h12v7.5H2z', 'M2 13h7'], + watermark: ['M2.5 2h11v12h-11z', 'M4.5 11.5 11.5 4.5'], wordCount: ['M2.5 2.5h11v11h-11z', 'M5 6h6M5 9h4'], // A speech bubble with its tail, the shape both word processors use for a comment. comment: ['M2 3.25h12v7.5H7.5L4.5 13.5v-2.75H2z', 'M4.75 6h6.5M4.75 8.25h4'], diff --git a/src/lib/components/ribbon/tabs/LayoutTab.svelte b/src/lib/components/ribbon/tabs/LayoutTab.svelte index 851e37f..eda0dd7 100644 --- a/src/lib/components/ribbon/tabs/LayoutTab.svelte +++ b/src/lib/components/ribbon/tabs/LayoutTab.svelte @@ -10,6 +10,8 @@ import type { Orientation } from '../../../storage/pageOrientation'; import type { HfZone } from '../../../storage/headerFooter'; import { DEFAULT_PAGE_NUMBERING, PAGE_NUM_FORMATS, clampPageStart, type PageNumbering } from '../../../storage/pageNumbering'; + import { EMPTY_PAGE_DECOR, type PageDecor } from '../../../storage/pageDecor'; + import PageDecorDialog from '../../PageDecorDialog.svelte'; import { formatOrdinal } from '../../../utils/orderedListTypes'; import { t } from '../../../i18n/i18n.svelte'; import { shortcutHint } from '../../../editor/shortcuts'; @@ -21,6 +23,7 @@ pageFormat = $bindable('A4'), hyphenate = $bindable(false), pageNumbering = $bindable(DEFAULT_PAGE_NUMBERING), + pageDecor = $bindable(EMPTY_PAGE_DECOR), onParagraphDialog, }: { editor: Editor | null; @@ -31,9 +34,12 @@ pageFormat?: PageFormat; hyphenate?: boolean; pageNumbering?: PageNumbering; + pageDecor?: PageDecor; onParagraphDialog?: () => void; } = $props(); + let decorOpen = $state(false); + const FORMATS = Object.keys(PAGE_FORMAT_CM) as PageFormat[]; const EDGES = ['top', 'bottom', 'left', 'right'] as const; @@ -235,8 +241,18 @@
{/if}
+ + (decorOpen = true)} + /> + +