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)} + /> + +