feat: page background, page border and watermark

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.
This commit is contained in:
Steffen Becker committed 2026-08-14 06:50:25 +02:00
1 parent f455a7c82f
commit 96d827b2c7
19 files changed
+867 -15

No files matched your search

+1 -1
View File
@@ -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 `<text:sequence>` (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
+10 -3
View File
@@ -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<typeof buildOdt>[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<typeof buildOdt>[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<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);
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}
+8
View File
@@ -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 `<svg><text textLength>` 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).
+7 -2
View File
@@ -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 @@
<!-- Reserves the scaled scroll footprint; the transform on .paper reserves none.
Before the first measure (size 0) it's left unsized so .paper isn't clipped. -->
<div class="paper-scaler" style={scaledWidth ? `width: ${scaledWidth}px; height: ${scaledHeight}px;` : ''}>
<div bind:this={paperEl} class="paper" data-spacing-model={spacingModel} class:show-formatting-marks={showFormattingMarks} class:hf-editing={hfActive} class:settling style="transform: scale({appliedZoom / 100});">
<div bind:this={paperEl} class="paper" data-spacing-model={spacingModel} class:show-formatting-marks={showFormattingMarks} class:hf-editing={hfActive} class:settling style="transform: scale({appliedZoom / 100});{pageDecor.background ? ` --color-page-bg: ${pageDecor.background};` : ''}">
<!-- Dedicated mount point that TipTap fully owns — keeping it free of Svelte
content avoids Svelte and ProseMirror fighting over the same parent's DOM. -->
<div bind:this={element} class="tiptap-host" dir={pageRtl ? 'rtl' : null} lang={documentLanguage === NO_LANGUAGE ? null : documentLanguage} style:hyphens={hyphenate ? 'auto' : null}></div>
@@ -1004,6 +1008,7 @@
{/each}
</div>
{/if}
<PageDecorLayer decor={pageDecor} {numPages} {pageMargins} {pageFormat} {orientation} />
<HeaderFooterLayer
bind:headerDoc
bind:footerDoc
+188
View File
@@ -0,0 +1,188 @@
<script lang="ts">
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<HTMLDialogElement | null>(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<PageBorder>) {
decor = { ...decor, border: { ...(decor.border ?? DEFAULT_PAGE_BORDER), ...next } };
}
function patchWatermark(next: Partial<Watermark>) {
decor = { ...decor, watermark: { ...(decor.watermark ?? DEFAULT_WATERMARK), ...next } };
}
</script>
<dialog
bind:this={dialogEl}
onclose={() => (open = false)}
onclick={(e) => e.target === dialogEl && (open = false)}
aria-label={t().pageDecor.title}
>
<div class="body">
<h2>{t().pageDecor.title}</h2>
<fieldset>
<legend>{t().pageDecor.background}</legend>
<label class="row">
<input
type="checkbox"
checked={!!decor.background}
onchange={(e) => (decor = { ...decor, background: e.currentTarget.checked ? (decor.background ?? '#ffffcc') : null })}
/>
<span>{t().pageDecor.useBackground}</span>
<input
type="color"
value={decor.background ?? '#ffffcc'}
onchange={(e) => (decor = { ...decor, background: e.currentTarget.value })}
/>
</label>
</fieldset>
<fieldset>
<legend>{t().pageDecor.border}</legend>
<label class="row">
<input
type="checkbox"
checked={!!decor.border}
onchange={(e) => (decor = { ...decor, border: e.currentTarget.checked ? (decor.border ?? DEFAULT_PAGE_BORDER) : null })}
/>
<span>{t().pageDecor.useBorder}</span>
<input type="color" value={decor.border?.color ?? DEFAULT_PAGE_BORDER.color} onchange={(e) => patchBorder({ color: e.currentTarget.value })} />
</label>
<label class="row">
<span class="grow">{t().pageDecor.borderWidth}</span>
<input type="number" min="0.25" max="20" step="0.25" disabled={!decor.border}
value={decor.border?.widthPt ?? DEFAULT_PAGE_BORDER.widthPt} onchange={(e) => patchBorder({ widthPt: num(e) })} />
</label>
<label class="row">
<span class="grow">{t().pageDecor.borderPadding}</span>
<input type="number" min="0" max="5" step="0.05" disabled={!decor.border}
value={decor.border?.paddingCm ?? DEFAULT_PAGE_BORDER.paddingCm} onchange={(e) => patchBorder({ paddingCm: num(e) })} />
</label>
</fieldset>
<fieldset>
<legend>{t().pageDecor.watermark}</legend>
<label class="row">
<span class="grow">{t().pageDecor.watermarkText}</span>
<input
type="text"
value={decor.watermark?.text ?? ''}
placeholder={t().pageDecor.watermarkHint}
oninput={(e) => {
const text = e.currentTarget.value;
decor = text ? { ...decor, watermark: { ...(decor.watermark ?? DEFAULT_WATERMARK), text } } : { ...decor, watermark: null };
}}
/>
</label>
<label class="row">
<span class="grow">{t().pageDecor.watermarkColor}</span>
<input type="color" value={decor.watermark?.color ?? DEFAULT_WATERMARK.color} disabled={!decor.watermark}
onchange={(e) => patchWatermark({ color: e.currentTarget.value })} />
</label>
<label class="row">
<span class="grow">{t().pageDecor.watermarkAngle}</span>
<input type="number" min="-180" max="180" step="5" disabled={!decor.watermark}
value={decor.watermark?.angle ?? DEFAULT_WATERMARK.angle} onchange={(e) => patchWatermark({ angle: num(e) })} />
</label>
<label class="row">
<span class="grow">{t().pageDecor.watermarkTransparency}</span>
<input type="number" min="0" max="100" step="5" disabled={!decor.watermark}
value={decor.watermark?.transparency ?? DEFAULT_WATERMARK.transparency} onchange={(e) => patchWatermark({ transparency: num(e) })} />
</label>
</fieldset>
<div class="actions">
<button class="reset" onclick={() => (decor = { ...EMPTY_PAGE_DECOR })}>{t().pageDecor.clear}</button>
<span class="spacer"></span>
<button class="primary" onclick={() => (open = false)}>{t().common.close}</button>
</div>
</div>
</dialog>
<style>
dialog {
/* The global reset zeroes every margin, which also takes the auto centring a
modal <dialog> gets by default. */
margin: auto;
border: none;
border-radius: 8px;
padding: 0;
background: var(--color-surface);
color: var(--color-text);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
dialog::backdrop { background: rgba(0, 0, 0, 0.35); }
.body {
display: flex;
flex-direction: column;
gap: 10px;
width: 380px;
padding: 18px 20px 16px;
font-family: var(--font-sans);
font-size: 0.85rem;
}
h2 { font-size: 1rem; }
fieldset { border: 1px solid var(--color-border); border-radius: var(--radius); padding: 8px 10px 10px; }
legend { color: var(--color-text-muted); padding: 0 4px; }
.row { display: flex; align-items: center; gap: 8px; padding: 3px 0; }
.row .grow { flex: 1; color: var(--color-text-muted); }
.row span { color: var(--color-text-muted); }
input[type='text'], input[type='number'] {
height: 26px;
width: 130px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
padding: 0 6px;
font: inherit;
}
input[type='color'] { width: 44px; height: 26px; padding: 2px; border: 1px solid var(--color-border); border-radius: var(--radius); background: var(--color-surface); }
input:disabled { opacity: 0.5; }
.actions { display: flex; align-items: center; gap: 8px; }
.spacer { flex: 1; }
.actions button {
border: 1px solid var(--color-primary);
border-radius: var(--radius);
background: var(--color-primary);
color: #fff;
padding: 5px 14px;
font: inherit;
cursor: pointer;
}
.actions .reset {
border-color: var(--color-border);
background: var(--color-surface);
color: var(--color-text-muted);
}
.actions .reset:hover { background: var(--color-btn-hover); }
</style>
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts">
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
import { cmToPx, PX_PER_CM, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import type { PageDecor } from '../storage/pageDecor';
// The page's border and watermark, one box per page — the background itself is a
// custom property on .paper, which already paints the sheet. Geometry is unscaled
// document px: the layer sits inside .paper, so the zoom transform covers it.
let { decor, numPages, pageMargins, pageFormat, orientation }: {
decor: PageDecor;
numPages: number;
pageMargins: PageMargins;
pageFormat: PageFormat;
orientation: Orientation;
} = $props();
const PAGE_GAP = 20;
// The watermark's aspect ratio, measured off LibreOffice's own shape.
const WATERMARK_RATIO = 4.487;
let dims = $derived(pageDimsCm(pageFormat, orientation));
let pageHeightPx = $derived(dims.h * PX_PER_CM);
let cycle = $derived(pageHeightPx + PAGE_GAP);
let pages = $derived(Array.from({ length: Math.max(1, numPages) }, (_, i) => i + 1));
// The border rings the text area, grown by its own padding — where ODF's fo:padding
// and Word's w:space put it.
let inset = $derived.by(() => {
const pad = cmToPx(decor.border?.paddingCm ?? 0);
const swap = (page: number) => pageMargins.mirrored === true && page % 2 === 0;
return (page: number) => ({
top: cmToPx(pageMargins.top) - pad,
bottom: cmToPx(pageMargins.bottom) - pad,
left: cmToPx(swap(page) ? pageMargins.right : pageMargins.left) - pad,
right: cmToPx(swap(page) ? pageMargins.left : pageMargins.right) - pad,
});
});
let markWidth = $derived((dims.w - pageMargins.left - pageMargins.right) * PX_PER_CM);
</script>
<div class="page-decor-layer" aria-hidden="true">
{#each pages as p}
{#if decor.border}
{@const box = inset(p)}
<div
class="page-border"
style="top: {(p - 1) * cycle + box.top}px; left: {box.left}px;
width: {dims.w * PX_PER_CM - box.left - box.right}px;
height: {pageHeightPx - box.top - box.bottom}px;
border: {decor.border.widthPt}pt solid {decor.border.color};"
></div>
{/if}
{#if decor.watermark}
<div class="watermark" style="top: {(p - 1) * cycle}px; height: {pageHeightPx}px;">
<!-- A fontwork shape stretches its text to the box rather than setting it at a
size, which is what textLength does here — the box is the text width, as
LibreOffice sizes its own watermark. -->
<svg
width={markWidth}
height={markWidth / WATERMARK_RATIO}
viewBox="0 0 {markWidth} {markWidth / WATERMARK_RATIO}"
style="transform: rotate({-decor.watermark.angle}deg); opacity: {1 - decor.watermark.transparency / 100};"
aria-hidden="true"
>
<text
x="0"
y={markWidth / WATERMARK_RATIO / 2}
dominant-baseline="central"
textLength={markWidth}
lengthAdjust="spacingAndGlyphs"
font-family="{decor.watermark.font}, sans-serif"
font-size={markWidth / WATERMARK_RATIO}
font-weight="700"
fill={decor.watermark.color}
>{decor.watermark.text}</text>
</svg>
</div>
{/if}
{/each}
</div>
<style>
/* Below the body text, like .hf-bg-layer: both word processors draw the watermark
behind what is written over it. */
.page-decor-layer {
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
}
.page-border,
.watermark {
position: absolute;
}
.watermark {
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.watermark svg {
overflow: visible;
}
</style>
+4 -1
View File
@@ -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<PageFormat>('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'}
<InsertTab {editor} {tick} {hfActive} {pageMargins} {pageOrientation} {pageFormat} {onEditZone} {onManageTableStyles} />
{:else if tab === 'layout'}
<LayoutTab {editor} {tick} {hfActive} bind:pageMargins bind:pageOrientation bind:pageFormat bind:hyphenate bind:pageNumbering onParagraphDialog={() => (paragraphDialogOpen = true)} />
<LayoutTab {editor} {tick} {hfActive} bind:pageMargins bind:pageOrientation bind:pageFormat bind:hyphenate bind:pageNumbering bind:pageDecor onParagraphDialog={() => (paragraphDialogOpen = true)} />
{:else if tab === 'references'}
<ReferencesTab {editor} {tick} {hfActive} {onNoteOptions} />
{:else if tab === 'review'}
+1
View File
@@ -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'],
@@ -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<PageFormat>('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 @@
</div>
{/if}
</div>
<RibbonButton
icon="watermark"
label={t().ribbon.pageDecor}
title={t().pageDecor.title}
active={!!(pageDecor.background || pageDecor.border || pageDecor.watermark)}
onclick={() => (decorOpen = true)}
/>
</RibbonGroup>
<PageDecorDialog bind:open={decorOpen} bind:decor={pageDecor} />
<div class="ribbon-sep"></div>
<!-- Absolute indent and spacing: the ruler can drag these, but only here can a
+112 -2
View File
@@ -35,6 +35,7 @@ import { defaultBulletChar } from '../utils/bulletListTypes';
import { normalizeColor, GENERATOR, MAX_HEADING_LEVEL, mergeJoinedParagraphsJson, twinFontName, type HfExport } from './odt';
import { EMPTY_DOC_PROPERTIES, type DocProperties } from '../storage/docProperties';
import { DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../storage/pageNumbering';
import { EMPTY_PAGE_DECOR, isEmptyPageDecor, type PageDecor, type Watermark } from '../storage/pageDecor';
// The five page-number formats both word processors offer → Word's own names.
const DOCX_PAGE_NUM_FORMAT = {
@@ -839,6 +840,106 @@ function applyMirrorMarginsDocx(bytes: Uint8Array): Uint8Array {
return zipSync(out);
}
// Post-pack pass: the page's own decoration. Word keeps the background on w:document
// (switched on in settings.xml), the border in every w:sectPr, and the watermark as a
// VML fontwork shape in each header part — the shapes LibreOffice writes, probed.
function applyPageDecorDocx(bytes: Uint8Array, decor: PageDecor, widthPt: number, heightPt: number): Uint8Array {
const files = unzipSync(bytes);
const docBytes = files['word/document.xml'];
if (!docBytes) return bytes;
let doc = strFromU8(docBytes);
if (decor.background) {
const color = decor.background.replace('#', '').toUpperCase();
doc = doc.replace(/(<w:document\b[^>]*>)/, `$1<w:background w:color="${color}"/>`);
const setBytes = files['word/settings.xml'];
// Without this Word stores the colour but paints nothing.
if (setBytes) {
files['word/settings.xml'] = strToU8(
strFromU8(setBytes).replace(/(<w:settings\b[^>]*>)/, '$1<w:displayBackgroundShape/>'),
);
}
}
if (decor.border) {
const color = decor.border.color.replace('#', '').toUpperCase();
// w:sz is eighths of a point, w:space whole points from the text.
const sz = Math.max(2, Math.round(decor.border.widthPt * 8));
const space = Math.min(31, Math.round((decor.border.paddingCm / 2.54) * 72));
const side = (name: string) => `<w:${name} w:val="single" w:sz="${sz}" w:space="${space}" w:color="${color}"/>`;
const borders = `<w:pgBorders w:display="allPages" w:offsetFrom="text">`
+ `${side('top')}${side('left')}${side('bottom')}${side('right')}</w:pgBorders>`;
// w:sectPr has a fixed child order and pgBorders belongs after pgMar; put ahead of
// the header references, Word's reader drops those instead (probed in LibreOffice).
doc = doc.replace(/<w:pgMar\b[^>]*\/>/g, (m) => `${m}${borders}`);
}
files['word/document.xml'] = strToU8(doc);
const wm = decor.watermark;
if (wm?.text) {
const shape = watermarkVml(wm, widthPt, heightPt);
for (const path of Object.keys(files)) {
if (!/^word\/header\d*\.xml$/.test(path)) continue;
const xml = strFromU8(files[path]);
if (xml.includes(WATERMARK_NAME)) continue;
// The shape has to sit inside a paragraph — as a child of w:hdr it is dropped
// without a word. An empty zone serializes as a self-closed <w:p/>.
files[path] = strToU8(xml.replace(/<w:p\b[^>]*\/>|(<w:p\b[^>]*>(?:<w:pPr>.*?<\/w:pPr>)?)/,
(m, opening: string | undefined) =>
opening ? `${opening}<w:r>${shape}</w:r>` : `<w:p><w:r>${shape}</w:r></w:p>`));
}
}
const out: Record<string, [Uint8Array, { level: 6 }]> = {};
for (const [path, data] of Object.entries(files)) out[path] = [data, { level: 6 }];
return zipSync(out);
}
const WATERMARK_NAME = 'PowerPlusWaterMarkObject';
// The shape's aspect ratio, measured off LibreOffice's own watermark.
const WATERMARK_RATIO = 4.487;
// Word has the `_x0000_t136` WordArt preset built in; LibreOffice resolves the shape's
// type only when the definition travels with it (probed: without this the watermark is
// silently dropped), so it is written out the way LibreOffice writes it itself.
const WATERMARK_SHAPETYPE =
'<v:shapetype id="_x0000_t136" coordsize="21600,21600" o:spt="136" adj="10800"' +
' path="m@7,l@8,m@5,21600l@6,21600e"><v:formulas>' +
'<v:f eqn="sum #0 0 10800"/><v:f eqn="prod #0 2 1"/><v:f eqn="sum 21600 0 @1"/>' +
'<v:f eqn="sum 0 0 @2"/><v:f eqn="sum 21600 0 @3"/><v:f eqn="if @0 @3 0"/>' +
'<v:f eqn="if @0 21600 @1"/><v:f eqn="if @0 0 @2"/><v:f eqn="if @0 @4 21600"/>' +
'<v:f eqn="mid @5 @6"/><v:f eqn="mid @8 @5"/><v:f eqn="mid @7 @8"/>' +
'<v:f eqn="mid @6 @7"/><v:f eqn="sum @6 0 @5"/></v:formulas>' +
'<v:path textpathok="t" o:connecttype="custom" o:connectlocs="@9,0;@10,10800;@11,21600;@12,10800"' +
' o:connectangles="270,180,90,0"/><v:textpath on="t" fitshape="t"/>' +
'<v:handles><v:h position="#0,bottomRight" xrange="6629,14971"/></v:handles>' +
'<o:lock v:ext="edit" text="t" shapetype="t"/></v:shapetype>';
// Word's WordArt shape for a watermark: the `_x0000_t136` preset, positioned relative to
// the margins and centred, which is what makes it sit behind the whole page.
function watermarkVml(wm: Watermark, widthPt: number, heightPt: number): string {
const w = Math.round(widthPt * 10) / 10;
const h = Math.round((widthPt / WATERMARK_RATIO) * 10) / 10;
// VML counts rotation clockwise, the dialog counter-clockwise.
const rotation = ((-wm.angle % 360) + 360) % 360;
const opacity = Math.round((1 - wm.transparency / 100) * 100) / 100;
const style = `position:absolute;margin-left:0pt;margin-top:${Math.round((heightPt - h) / 2 * 10) / 10}pt;`
+ `width:${w}pt;height:${h}pt;mso-wrap-style:none;v-text-anchor:middle;rotation:${rotation};`
+ 'mso-position-horizontal:center;mso-position-horizontal-relative:margin;'
+ 'mso-position-vertical:center;mso-position-vertical-relative:margin';
return '<w:pict>' + WATERMARK_SHAPETYPE
+ `<v:shape id="${WATERMARK_NAME}" o:spid="_x0000_s2049" type="#_x0000_t136" adj="10800"`
+ ` fillcolor="${wm.color}" stroked="f" o:allowincell="f" style="${style}">`
+ '<v:path textpathok="t"/>'
+ `<v:textpath on="t" fitshape="t" string="${escapeXmlAttr(wm.text)}"`
+ ` style="font-family:&quot;${escapeXmlAttr(wm.font)}&quot;;font-size:1pt" trim="t"/>`
+ `<v:fill type="solid" opacity="${opacity}"/><w10:wrap type="none"/></v:shape></w:pict>`;
}
const escapeXmlAttr = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// Word's num-format ids for the five ODF ones.
const DOCX_NUM_FMT: Record<string, string> = {
'1': 'decimal', a: 'lowerLetter', A: 'upperLetter', i: 'lowerRoman', I: 'upperRoman',
@@ -1398,6 +1499,7 @@ export async function buildDocx(
props: DocProperties = EMPTY_DOC_PROPERTIES,
hyphenate = false,
pageNumbering: PageNumbering = DEFAULT_PAGE_NUMBERING,
decor: PageDecor = EMPTY_PAGE_DECOR,
): Promise<Uint8Array> {
docLangTag = localeTag(language ? language.language : 'en');
exportSheet = styles;
@@ -1477,9 +1579,12 @@ export async function buildDocx(
const mkHeaders = (i: number) => {
const s = setAt(i);
const d = para(s.header), f = s.differentFirstPage ? para(s.headerFirst) : null, e = s.differentOddEven ? para(s.headerEven) : null;
if (!d && !f && !e) return undefined;
// A watermark lives in a header part, so a document without one still needs the
// (empty) default header for applyPageDecorDocx to inject the shape into.
if (!d && !f && !e) return decor.watermark?.text ? { default: new Header({ children: [new Paragraph({})] }) } : undefined;
const h: { default?: Header; first?: Header; even?: Header } = {};
if (d) h.default = new Header({ children: [paragraphToDocx(d)] });
else if (decor.watermark?.text) h.default = new Header({ children: [new Paragraph({})] });
if (f) h.first = new Header({ children: [paragraphToDocx(f)] });
if (e) h.even = new Header({ children: [paragraphToDocx(e)] });
return h;
@@ -1540,5 +1645,10 @@ export async function buildDocx(
const packed = applyFormulasDocx(applyTextBoxesDocx(new Uint8Array(await blob.arrayBuffer()), textBoxes), docFormulas);
const withNotes = docNoteIds.size ? applyNotePrDocx(packed, notesSettings) : packed;
const mirrored = margins.mirrored ? applyMirrorMarginsDocx(withNotes) : withNotes;
return rtl ? applyBidiDocx(mirrored) : mirrored;
const bidi = rtl ? applyBidiDocx(mirrored) : mirrored;
if (isEmptyPageDecor(decor)) return bidi;
const dims = pageDimsCm(pageFormat, orientation);
const pt = (cm: number) => (cm / 2.54) * 72;
return applyPageDecorDocx(bidi, decor,
pt(dims.w - margins.left - margins.right), pt(dims.h - margins.top - margins.bottom));
}
+105 -4
View File
@@ -9,6 +9,7 @@ import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc, type HfSet } from '../storage/he
import { DEFAULT_NOTE_SETTINGS, NOTE_FONT_SIZE_PT, NOTE_INDENT_CM, type NoteKind, type NoteNumFormat, type NoteSettings } from '../storage/noteSettings';
import { EMPTY_DOC_PROPERTIES, keywordList, type DocProperties } from '../storage/docProperties';
import { DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../storage/pageNumbering';
import { EMPTY_PAGE_DECOR, type PageDecor, type Watermark } from '../storage/pageDecor';
import { builtinStyleSheet, DEFAULT_STYLE, resolveStyle, type StyleSheet, type TextProps } from '../styles/styleSheet';
import {
TABLE_REGIONS, parseTableLook, regionText, type TableLook, type TableRegion,
@@ -2365,7 +2366,95 @@ function rezipOdt(files: Record<string, Uint8Array>): Uint8Array {
// Rewrite styles.xml to match the editor's preview: default font Liberation Serif →
// Times New Roman (metric-identical; see EXPORT_FONT), and Heading_20_1/2/3 sizes &
// margins → the editor's values (odf-kit's defaults are larger; HEADING_STYLE_OVERRIDES).
function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null, pageFormat: PageFormat, orientation: Orientation, sheet: StyleSheet, used: Set<string>, usedTables: Set<string> = new Set(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, mirrored = false, rtl = false, notes: NoteSettings = DEFAULT_NOTE_SETTINGS, hyphenate = false, pageNumbering: PageNumbering = DEFAULT_PAGE_NUMBERING): Uint8Array {
// LibreOffice's watermark is a fontwork shape in the master page's header, and the
// enhanced geometry below is the "fontwork-plain-text" preset it writes verbatim.
const WATERMARK_GEOMETRY =
'<draw:enhanced-geometry svg:viewBox="0 0 21600 21600" draw:text-areas="0 0 21600 21600"' +
' draw:text-path="true" draw:type="fontwork-plain-text" draw:modifiers="10800"' +
' draw:enhanced-path="M ?f3 0 L ?f5 0 N M ?f6 21600 L ?f7 21600 N">' +
'<draw:equation draw:name="f0" draw:formula="$0 -10800"/>' +
'<draw:equation draw:name="f1" draw:formula="?f0 *2"/>' +
'<draw:equation draw:name="f2" draw:formula="abs(?f1 )"/>' +
'<draw:equation draw:name="f3" draw:formula="if(?f1 ,0,?f2 )"/>' +
'<draw:equation draw:name="f4" draw:formula="21600-?f2 "/>' +
'<draw:equation draw:name="f5" draw:formula="if(?f1 ,?f4 ,21600)"/>' +
'<draw:equation draw:name="f6" draw:formula="if(?f1 ,?f2 ,0)"/>' +
'<draw:equation draw:name="f7" draw:formula="if(?f1 ,21600,?f4 )"/>' +
'</draw:enhanced-geometry>';
// LibreOffice's own shape name; Word looks for exactly this one, so a round trip
// through either keeps recognising the shape as a watermark rather than a drawing.
const WATERMARK_NAME = 'PowerPlusWaterMarkObject';
// The shape's aspect ratio, measured off LibreOffice's own output (17cm × 3.789cm).
const WATERMARK_RATIO = 4.487;
// The watermark shape plus its two automatic styles, spliced into the master page's
// header — where both word processors keep it. A document without a header gets an
// explicit zero-height header band, so the body's geometry is unchanged.
function applyWatermarkOdf(odtBytes: Uint8Array, wm: Watermark | null): Uint8Array {
if (!wm?.text) return odtBytes;
const files = unzipSync(odtBytes);
const stylesBytes = files['styles.xml'];
if (!stylesBytes) return odtBytes;
const styles = strFromU8(stylesBytes);
const round3 = (v: number) => Math.round(v * 1000) / 1000;
const widthCm = Math.max(1, lengthOfPageLayout(styles) );
const heightCm = round3(widthCm / WATERMARK_RATIO);
const opacity = `${round3(100 - wm.transparency)}%`;
const minted =
`<style:style style:name="Wmk1" style:family="graphic"><style:graphic-properties` +
` draw:stroke="none" draw:fill="solid" draw:fill-color="${wm.color}" draw:opacity="${opacity}"` +
` draw:auto-grow-height="false" draw:auto-grow-width="false"` +
` fo:min-height="${heightCm}cm" fo:min-width="${round3(widthCm)}cm"` +
` style:run-through="background" style:wrap="run-through" style:number-wrapped-paragraphs="no-limit"` +
` style:vertical-pos="middle" style:vertical-rel="page-content"` +
` style:horizontal-pos="center" style:horizontal-rel="page-content" style:flow-with-text="false"/></style:style>` +
`<style:style style:name="WmkP1" style:family="paragraph"><style:text-properties` +
` fo:font-family="${escapeXml(`'${wm.font}'`)}" fo:font-size="1pt"/></style:style>`;
const shape =
`<draw:custom-shape text:anchor-type="char" draw:z-index="0" draw:name="${WATERMARK_NAME}"` +
` draw:style-name="Wmk1" draw:text-style-name="WmkP1"` +
` svg:width="${round3(widthCm)}cm" svg:height="${heightCm}cm"` +
` draw:transform="rotate (${(wm.angle * Math.PI) / 180})">` +
`<text:p>${escapeXml(wm.text)}</text:p>${WATERMARK_GEOMETRY}</draw:custom-shape>`;
// odf-kit declares no draw: prefix in styles.xml, and an undeclared one makes
// LibreOffice drop the whole shape without a word.
let out = styles.includes('xmlns:draw=')
? styles
: styles.replace(/<office:document-styles\b/, '<office:document-styles xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"');
out = injectAutomaticStyles(out, minted);
// Into the header the document already has — the shape is out of flow, so it changes
// neither the band nor the zone's text.
if (out.includes('<style:header>')) {
out = out.replace(/<style:header>(\s*<text:p[^>]*>)?/, (m) =>
m.includes('<text:p') ? `${m}${shape}` : `${m}<text:p text:style-name="Header">${shape}</text:p>`);
} else {
// No header: give the master page one that reserves no band of its own. LibreOffice
// still floors the band at 0.499cm, so the body shifts by that much — its own
// watermark does the same.
const zone = `<style:header><text:p text:style-name="Header">${shape}</text:p></style:header>`;
out = out.replace(/<style:header-style\s*\/>/, '<style:header-style><style:header-footer-properties fo:min-height="0cm" fo:margin-bottom="0cm"/></style:header-style>');
out = /<style:master-page\b[^>]*\/>/.test(out)
? out.replace(/(<style:master-page\b[^>]*)\/>/, `$1>${zone}</style:master-page>`)
: out.replace(/(<style:master-page\b[^>]*>)/, `$1${zone}`);
}
files['styles.xml'] = strToU8(out);
return rezipOdt(files);
}
// The text width the page layout declares, in cm — the watermark spans it, as
// LibreOffice's does.
function lengthOfPageLayout(styles: string): number {
const num = (attr: string) => {
const m = new RegExp(`${attr}="([\\d.]+)cm"`).exec(styles);
return m ? parseFloat(m[1]) : 0;
};
const width = num('fo:page-width');
return width ? width - num('fo:margin-left') - num('fo:margin-right') : 17;
}
function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null, pageFormat: PageFormat, orientation: Orientation, sheet: StyleSheet, used: Set<string>, usedTables: Set<string> = new Set(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, mirrored = false, rtl = false, notes: NoteSettings = DEFAULT_NOTE_SETTINGS, hyphenate = false, pageNumbering: PageNumbering = DEFAULT_PAGE_NUMBERING, decor: PageDecor = EMPTY_PAGE_DECOR): Uint8Array {
const files = unzipSync(odtBytes);
const stylesBytes = files['styles.xml'];
if (!stylesBytes) return odtBytes;
@@ -2391,6 +2480,17 @@ function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; countr
styles = styles.replace(/<style:page-layout-properties /, '<style:page-layout-properties style:writing-mode="rl-tb" ');
}
// Page background and page border ride the page layout, exactly where LibreOffice
// keeps them (probed). The border is offset from the text area by fo:padding.
const decorProps = [
decor.border ? `fo:border="${round3(decor.border.widthPt)}pt solid ${decor.border.color}"` : '',
decor.border ? `fo:padding="${round3(decor.border.paddingCm)}cm"` : '',
decor.background ? `fo:background-color="${decor.background}"` : '',
].filter(Boolean).join(' ');
if (decorProps) {
styles = styles.replace(/<style:page-layout-properties /, `<style:page-layout-properties ${decorProps} `);
}
// How the page-number field counts. ODF keeps the format on the page layout; the start
// value is a property of the document's first paragraph (applyPageNumberStart).
if (pageNumbering.format !== '1') {
@@ -3780,7 +3880,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): 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): 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).
@@ -4011,11 +4111,12 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
// Effects first: applyCharacterStyles then clones the style that already carries them.
const withNamedStyles = applyCharacterStyles(applyTextEffects(applyParagraphStyles(withParaBoxes)));
const usedTables = new Set(tableStyleNames.filter((t): t is TableStyleRef => !!t).map(t => t.name));
const withStyles = rewriteStylesXml(withNamedStyles, language ?? null, pageFormat, orientation, styles, usedStyleNames(docJson, styles), usedTables, tabIntervalCm, margins.mirrored === true, rtl, notesSettings, hyphenate, pageNumbering);
const withStyles = rewriteStylesXml(withNamedStyles, language ?? null, pageFormat, orientation, styles, usedStyleNames(docJson, styles), usedTables, tabIntervalCm, margins.mirrored === true, rtl, notesSettings, hyphenate, pageNumbering, decor);
const withHf = applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist, firstHeaderPara, firstFooterPara, hf?.pageCount ?? 1, hfImages, evenHeaderPara, evenFooterPara);
const withWatermark = applyWatermarkOdf(withHf, decor.watermark);
// Sections past the first get their own master page, which is where ODF keeps a
// section's header/footer; the SEC-marked block points at it.
const withSections = applySectionMasterPages(withHf, hf?.sections ?? [], hf?.pageCount ?? 1, margins);
const withSections = applySectionMasterPages(withWatermark, hf?.sections ?? [], hf?.pageCount ?? 1, margins);
return applyDocProperties(applyPageNumberStart(applySpacingModel(withSections, spacingModel), pageNumbering.start), props);
}
+17
View File
@@ -520,6 +520,22 @@ const de: Messages = {
above: 'Über dem Objekt',
below: 'Unter dem Objekt',
},
pageDecor: {
title: 'Seitengestaltung',
background: 'Hintergrund',
useBackground: 'Seite einfärben',
border: 'Umrandung',
useBorder: 'Seite umrahmen',
borderWidth: 'Breite (pt)',
borderPadding: 'Abstand zum Text (cm)',
watermark: 'Wasserzeichen',
watermarkText: 'Text',
watermarkHint: 'z. B. ENTWURF',
watermarkColor: 'Farbe',
watermarkAngle: 'Winkel (°)',
watermarkTransparency: 'Transparenz (%)',
clear: 'Alles entfernen',
},
notesDialog: {
title: 'Fuß- und Endnoten',
tabFootnotes: 'Fußnoten',
@@ -657,6 +673,7 @@ const de: Messages = {
repeatHeaderRowHint: 'Die erste Zeile oben auf jeder Seite wiederholen',
pageNumberFormat: 'Seitenzahlen',
pageNumberStart: 'Beginnen bei',
pageDecor: 'Seitengestaltung',
hyphenationHint: 'Automatisch trennen',
debugDump: 'Dump',
cellMargins: 'Zellenränder',
+17
View File
@@ -524,6 +524,22 @@ const en = {
above: 'Above the item',
below: 'Below the item',
},
pageDecor: {
title: 'Page decoration',
background: 'Background',
useBackground: 'Colour the page',
border: 'Border',
useBorder: 'Frame the page',
borderWidth: 'Width (pt)',
borderPadding: 'Distance from text (cm)',
watermark: 'Watermark',
watermarkText: 'Text',
watermarkHint: 'e.g. DRAFT',
watermarkColor: 'Colour',
watermarkAngle: 'Angle (°)',
watermarkTransparency: 'Transparency (%)',
clear: 'Remove all',
},
notesDialog: {
title: 'Footnotes and Endnotes',
tabFootnotes: 'Footnotes',
@@ -661,6 +677,7 @@ const en = {
repeatHeaderRowHint: 'Repeat the first row at the top of every page',
pageNumberFormat: 'Page numbers',
pageNumberStart: 'Start at',
pageDecor: 'Page design',
hyphenationHint: 'Hyphenate automatically',
debugDump: 'Dump',
cellMargins: 'Cell margins',
+56 -1
View File
@@ -1,6 +1,6 @@
import { unzipSync, strFromU8 } from 'fflate';
import { DocxStyles, parseRunProps, mergeRunProps, readNumPr, readTabStops, toggle as onOff, wVal, W, R, WP, A, WPS, MC, VML, PKG_REL, type RunProps, type ParaSpacing } from './docxStyles';
import { lengthToPt } from './styleResolver';
import { lengthToPt, WATERMARK_NAME } from './styleResolver';
import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, normalizeColor } from '../export/odt';
import { builtinStyleSheet, DEFAULT_STYLE, type ParaProps, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
@@ -27,6 +27,7 @@ import { deobfuscateOdttf, type EmbeddedFont } from '../fonts/embeddedFonts';
import { cellPaddingAttr, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding';
import { ODF_SEQ_CATEGORY } from '../editor/extensions/caption';
import type { IndexKind } from '../editor/extensions/tableOfContents';
import { normalizePageDecor, type PageDecor } from '../storage/pageDecor';
import { clampColumnGap } from '../editor/extensions/columns';
import { astToLatex } from '../math/latex';
import { parseOmml, OMML_NS } from '../math/omml';
@@ -236,6 +237,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
notes: docNoteSettings(files),
margins: withMirror(first.margins ?? sect.margins, mirrored),
rtl: sectPrRtl(finalSectPr),
decor: docxPageDecor(docDoc, finalSectPr, files),
hyphenate: docAutoHyphenation(files),
pageNumbering: docxPageNumbering(sectPr),
orientation: sect.orientation,
@@ -1751,6 +1753,9 @@ function convertPict(pict: Element, ctx: Ctx): Node | null {
(c) => c.namespaceURI === VML && ['shape', 'rect', 'oval', 'roundrect'].includes(c.localName),
);
if (!shape) { ctx.warnings.add('Drawings were removed'); return null; }
// The watermark rides the page decoration (storage/pageDecor.ts), not the header's
// content, so it must not also arrive here as a shape.
if (shape.getAttribute('id') === WATERMARK_NAME) return null;
const style = shape.getAttribute('style') ?? '';
const dimPx = (key: string): number | null => {
@@ -2164,6 +2169,56 @@ function splitBodySections(children: Element[]): {
return { groups, midSectPrs };
}
// The page's own decoration: Word keeps the background on w:document, the border in the
// section, and the watermark as a VML fontwork shape in a header part — the same three
// places LibreOffice writes them (probed).
function docxPageDecor(docDoc: Document, sectPr: Element | null, files: Record<string, Uint8Array>): PageDecor {
const bg = fc(docDoc.documentElement, 'background')?.getAttributeNS(W, 'color');
const borders = fc(sectPr, 'pgBorders');
const top = borders ? fc(borders, 'top') : null;
const sz = top ? intAttr(top, W, 'sz') : null;
const color = top?.getAttributeNS(W, 'color');
return normalizePageDecor({
background: bg && bg !== 'auto' ? `#${bg}` : null,
// w:sz is eighths of a point, w:space whole points from the text.
border: sz && top && wVal(top) !== 'none'
? { widthPt: Math.round((sz / 8) * 100) / 100,
color: color && color !== 'auto' ? `#${color}` : '#000000',
paddingCm: Math.round(((intAttr(top, W, 'space') ?? 0) / 72) * 2.54 * 100) / 100 }
: null,
watermark: docxWatermark(files),
});
}
// The VML shape Word and LibreOffice both name PowerPlusWaterMarkObject, in whichever
// header part carries it. Its text rides v:textpath, its angle the style's rotation.
function docxWatermark(files: Record<string, Uint8Array>): unknown {
for (const path of Object.keys(files)) {
if (!/^word\/header\d*\.xml$/.test(path)) continue;
const xml = strFromU8(files[path]);
const i = xml.indexOf(WATERMARK_NAME);
if (i < 0) continue;
const shape = xml.slice(i, xml.indexOf('</v:shape>', i));
const text = /\bstring="([^"]*)"/.exec(shape)?.[1];
if (!text) continue;
const style = /<v:textpath[^>]*\bstyle="([^"]*)"/.exec(shape)?.[1] ?? '';
const rotation = Number(/rotation:\s*(-?[\d.]+)/.exec(shape)?.[1] ?? '0');
const opacity = Number(/<v:fill[^>]*\bopacity="([\d.]+)"/.exec(shape)?.[1] ?? '1');
return {
text: decodeXml(text),
font: decodeXml(/font-family:\s*&quot;?([^;&"]+)/.exec(style)?.[1] ?? '').trim() || undefined,
color: /\bfillcolor="([^"]*)"/.exec(shape)?.[1],
// VML counts clockwise, the editor (and ODF) counter-clockwise.
angle: -(((rotation % 360) + 360) % 360) + (rotation > 180 ? 360 : 0),
transparency: Math.round((1 - opacity) * 100),
};
}
return null;
}
const decodeXml = (s: string) =>
s.replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
// A right-to-left section (w:bidi): the columns fill from the right, and it is the
// direction a block inherits when it declares none of its own.
function sectPrRtl(sectPr: Element | null): boolean {
+8 -1
View File
@@ -1,5 +1,5 @@
import { unzipSync, strFromU8 } from 'fflate';
import { StyleResolver, NS, lengthToPt, lengthToCm, layerTextProps, type PropMap } from './styleResolver';
import { StyleResolver, NS, WATERMARK_NAME, lengthToPt, lengthToCm, layerTextProps, type PropMap } from './styleResolver';
import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, ODF_LOOK_ATTRS, normalizeColor } from '../export/odt';
import { builtinStyleSheet, DEFAULT_STYLE, type ParaProps, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
@@ -26,6 +26,7 @@ import { clampPageStart, type PageNumbering } from '../storage/pageNumbering';
import { newCommentId } from '../editor/extensions/comment';
import { ODF_SEQ_CATEGORY } from '../editor/extensions/caption';
import type { IndexKind } from '../editor/extensions/tableOfContents';
import type { PageDecor } from '../storage/pageDecor';
import type { EmbeddedFont } from '../fonts/embeddedFonts';
import { cellPaddingAttr, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding';
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
@@ -54,6 +55,8 @@ export interface OdtImportResult {
// A right-to-left page (ODF style:writing-mode="rl-tb", Word w:bidi): the columns
// fill from the right and the body's base direction is RTL.
rtl: boolean;
// Page background, page border and watermark (storage/pageDecor.ts).
decor: PageDecor;
// Automatic hyphenation (ODF fo:hyphenate on the base style, Word w:autoHyphenation).
hyphenate: boolean;
// How the page-number field counts (ODF style:num-format + style:page-number, Word w:pgNumType).
@@ -508,6 +511,9 @@ function convertShape(el: Element, ctx: Ctx): Node | null {
// Dispatch any draw:* element: an image stays inline; a text box / shape is a block
// node routed through ctx.pendingBlocks; everything else is dropped with a warning.
function convertDrawElement(e: Element, ctx: Ctx): { inline?: Node; block?: Node } | null {
// The watermark is not a drawing: it rides the page decoration instead
// (storage/pageDecor.ts), so it must not also arrive as a shape in the header.
if (e.getAttributeNS(NS.draw, 'name') === WATERMARK_NAME) return null;
if (e.localName === 'frame') {
const textBoxEl = Array.from(e.children).find(
c => c.namespaceURI === NS.draw && c.localName === 'text-box',
@@ -686,6 +692,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
orientation: geometry?.orientation ?? null,
format: geometry?.format ?? null,
rtl: geometry?.rtl ?? false,
decor: resolver.pageDecor(),
hyphenate: resolver.documentHyphenation(),
pageNumbering: { format: resolver.pageNumberFormat(), start: odfPageNumberStart(resolver, body) },
tabIntervalCm: resolver.defaultTabInterval(),
+47
View File
@@ -4,6 +4,11 @@ import { formatFromCm, type PageFormat } from '../storage/pageFormat';
import { ODF_IMPLIED_TAB_CM } from '../storage/tabInterval';
import { normalizeLeader, type TabAlign, type TabStop } from '../editor/extensions/tabStops';
import { DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteNumFormat, type NoteSettings } from '../storage/noteSettings';
import { DEFAULT_WATERMARK, normalizePageDecor, type PageDecor, type Watermark } from '../storage/pageDecor';
// LibreOffice's (and Word's) name for the watermark shape — what tells it apart from an
// ordinary drawing in the header.
export const WATERMARK_NAME = 'PowerPlusWaterMarkObject';
// Resolves ODF style indirection for the importer: producers (our export, LibreOffice,
// Word) spread formatting across named/automatic styles and parent-style-name chains.
@@ -713,6 +718,48 @@ export class StyleResolver {
return { margins, orientation, format, rtl };
}
// The page's own decoration (storage/pageDecor.ts): background and border ride the
// page layout, the watermark is a fontwork shape in the master page's header —
// exactly where LibreOffice keeps all three (probed).
pageDecor(pageName: string | null = null): PageDecor {
const props = this.pageLayoutEl(pageName)?.getElementsByTagNameNS(NS.style, 'page-layout-properties')[0] ?? null;
const bg = props?.getAttributeNS(NS.fo, 'background-color') ?? null;
const border = props?.getAttributeNS(NS.fo, 'border')
?? props?.getAttributeNS(NS.fo, 'border-top') ?? null;
// '<width> <style> <color>' — the same shorthand a paragraph border uses.
const parts = border && border !== 'none' ? border.trim().split(/\s+/) : null;
const widthPt = parts ? lengthToPt(parts[0]) : null;
return normalizePageDecor({
background: bg && bg !== 'transparent' ? bg : null,
border: parts && widthPt
? { widthPt: Math.round(widthPt * 100) / 100, color: parts[2] ?? '#000000',
paddingCm: lengthToCm(props?.getAttributeNS(NS.fo, 'padding')) ?? 0 }
: null,
watermark: this.watermark(pageName),
});
}
private watermark(pageName: string | null): Watermark | null {
const master = this.masterPageEl(pageName);
const shapes = master?.getElementsByTagNameNS(NS.draw, 'custom-shape') ?? [];
const shape = Array.from(shapes).find((s) => s.getAttributeNS(NS.draw, 'name') === WATERMARK_NAME);
if (!shape) return null;
const text = (shape.textContent ?? '').trim();
if (!text) return null;
const gfx = this.graphicProps(shape.getAttributeNS(NS.draw, 'style-name'));
const opacity = parseFloat(String(gfx['draw:opacity'] ?? '100'));
// ODF stores the rotation in radians, counter-clockwise.
const rot = /rotate\s*\(\s*(-?[\d.eE+-]+)\s*\)/.exec(shape.getAttributeNS(NS.draw, 'transform') ?? '');
const font = this.merged('paragraph', shape.getAttributeNS(NS.draw, 'text-style-name') ?? '').text['fo:font-family'];
return {
text,
font: typeof font === 'string' ? font.replace(/^['"]|['"]$/g, '') : DEFAULT_WATERMARK.font,
color: String(gfx['draw:fill-color'] ?? DEFAULT_WATERMARK.color),
angle: rot ? Math.round((parseFloat(rot[1]) * 180) / Math.PI) : 0,
transparency: Number.isFinite(opacity) ? Math.round(100 - opacity) : 0,
};
}
// Raw page margins (cm) = ODF's edge→zone distance, i.e. the header distance from
// the top and footer distance from the bottom when a header/footer is present.
edgeDistancesCm(): { top: number; bottom: number } | null {
+1
View File
@@ -13,6 +13,7 @@
- **`writingMode.ts`** — the page's text direction (`loadPageRtl`/`savePageRtl`, `edentext-page-rtl`), from ODF `style:writing-mode="rl-tb"` or Word's section-level `w:bidi`. `Editor.svelte` puts it on the body host as `dir`, which is what makes a multi-column page fill from the right; the header/footer band is left alone (LibreOffice lays those out unchanged — measured). The vertical modes are not read.
- **`pageNumbering.ts`** — the page-number field's format and start value (`edentext-page-numbering`, absent at the 1/1 default). `HeaderFooterLayer.svelte` formats each page through `formatOrdinal`; the page **count** stays decimal, as the field both word processors write does. ODF keeps the format on the page layout and the start on the first paragraph's own style (`style:page-number` — probed, ODF has no document-level start); Word keeps both in the first section's `w:pgNumType`.
- **`hyphenation.ts`** — automatic hyphenation for the whole document (`edentext-hyphenation`, off by default as in both word processors). `Editor.svelte` puts `hyphens: auto` and the document `lang` on the body host — the browser needs the language to pick its patterns. ODF keeps it as `fo:hyphenate` in the **text** properties of the base paragraph style (probed: in paragraph-properties LibreOffice ignores it and drops it on the next save), Word as `w:autoHyphenation` in settings.xml.
- **`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`.
- **`pageOrientation.ts`** — `'portrait' | 'landscape'`. `applyOrientationVars` sets `--user-page-{width,height}`; landscape swaps the A4 dimensions (matching odf-kit's automatic swap).
Both margins and orientation are passed into the ODT export so the exported document's geometry/line-wrapping matches the on-screen preview.
+96
View File
@@ -0,0 +1,96 @@
// The page's own decoration: LibreOffice's Format ▸ Page Style ▸ Area and Borders, and
// its Format ▸ Watermark. All three are page-level and all three ride the page layout
// (the watermark rides the master page's header, where both word processors keep it),
// so they share one key. Every field is absent by default — a fresh document decorates
// nothing, and neither importer stamps a decoration on a file that has none.
const KEY = 'edentext-page-decor';
// A page border is one rule around the whole page, as both word processors draw it: one
// width and colour for all four sides, offset from the text by `paddingCm`.
export type PageBorder = { widthPt: number; color: string; paddingCm: number };
// LibreOffice's watermark dialog, field for field.
export type Watermark = {
text: string;
font: string;
color: string;
/** Degrees counter-clockwise from the baseline, as LibreOffice's dialog counts. */
angle: number;
/** Percent; 0 is opaque. */
transparency: number;
};
export type PageDecor = {
background: string | null;
border: PageBorder | null;
watermark: Watermark | null;
};
export const EMPTY_PAGE_DECOR: PageDecor = { background: null, border: null, watermark: null };
// LibreOffice's own defaults for a freshly switched-on border and watermark.
export const DEFAULT_PAGE_BORDER: PageBorder = { widthPt: 0.5, color: '#000000', paddingCm: 0.05 };
export const DEFAULT_WATERMARK: Watermark = {
text: '', font: 'Liberation Sans', color: '#c0c0c0', angle: 45, transparency: 50,
};
// Clamped and rounded to 2 dp: a border read back out of a file arrives through a
// unit conversion, and 0.2cm must compare equal to the 0.2cm that was written.
const clamp = (v: number, lo: number, hi: number) =>
Math.round(Math.min(hi, Math.max(lo, v)) * 100) / 100;
// A colour the exports can write verbatim: ODF and DOCX both take #RRGGBB only.
function hex(value: unknown, fallback: string | null): string | null {
return typeof value === 'string' && /^#[0-9a-fA-F]{6}$/.test(value) ? value.toLowerCase() : fallback;
}
function readBorder(raw: unknown): PageBorder | null {
const b = raw as Partial<PageBorder> | null | undefined;
if (!b || typeof b !== 'object') return null;
return {
widthPt: clamp(Number(b.widthPt) || DEFAULT_PAGE_BORDER.widthPt, 0.25, 20),
color: hex(b.color, DEFAULT_PAGE_BORDER.color)!,
paddingCm: clamp(Number(b.paddingCm) || 0, 0, 5),
};
}
function readWatermark(raw: unknown): Watermark | null {
const w = raw as Partial<Watermark> | null | undefined;
if (!w || typeof w !== 'object' || typeof w.text !== 'string' || !w.text) return null;
return {
text: w.text,
font: typeof w.font === 'string' && w.font ? w.font : DEFAULT_WATERMARK.font,
color: hex(w.color, DEFAULT_WATERMARK.color)!,
angle: clamp(Number(w.angle) || 0, -180, 180),
transparency: clamp(Number(w.transparency) || 0, 0, 100),
};
}
export function normalizePageDecor(raw: unknown): PageDecor {
const d = raw as Partial<PageDecor> | null | undefined;
if (!d || typeof d !== 'object') return EMPTY_PAGE_DECOR;
return {
background: hex(d.background, null),
border: readBorder(d.border),
watermark: readWatermark(d.watermark),
};
}
export function isEmptyPageDecor(d: PageDecor): boolean {
return !d.background && !d.border && !d.watermark;
}
export function loadPageDecor(): PageDecor {
try {
const raw = localStorage.getItem(KEY);
return raw ? normalizePageDecor(JSON.parse(raw)) : EMPTY_PAGE_DECOR;
} catch {
return EMPTY_PAGE_DECOR;
}
}
export function savePageDecor(decor: PageDecor): void {
if (isEmptyPageDecor(decor)) localStorage.removeItem(KEY);
else localStorage.setItem(KEY, JSON.stringify(decor));
}
+62
View File
@@ -14,6 +14,7 @@ import { buildDocx } from '../src/lib/export/docx';
import { importDocx } from '../src/lib/import/docx';
import { EMPTY_HF_SET } from '../src/lib/storage/headerFooter';
import { DEFAULT_NOTE_SETTINGS } from '../src/lib/storage/noteSettings';
import { DEFAULT_PAGE_NUMBERING } from '../src/lib/storage/pageNumbering';
type N = any;
@@ -2042,3 +2043,64 @@ describe('Leg 20: list of figures / list of tables', () => {
.content[0].attrs?.index === 'toc');
});
});
describe('Leg 21: page background, page border and watermark', () => {
const decor = {
background: '#fff5c8',
border: { widthPt: 1, color: '#0046a0', paddingCm: 0.2 },
watermark: { text: 'ENTWURF', font: 'Liberation Sans', color: '#c8c8c8', angle: 45, transparency: 50 },
};
const doc: N = { type: 'doc', content: [P(null, T('body'))] };
const odt = () => buildOdt(doc, margins, 'portrait', undefined, null, 'A4', builtinStyleSheet(),
1.25, 'add', false, DEFAULT_NOTE_SETTINGS, undefined, false, DEFAULT_PAGE_NUMBERING, decor);
const docx = () => buildDocx(doc, margins, 'portrait', undefined, null, 'A4', builtinStyleSheet(),
1.25, 'add', false, DEFAULT_NOTE_SETTINGS, undefined, false, DEFAULT_PAGE_NUMBERING, decor);
it('ODT: all three on the page layout and the master page, and back', async () => {
const bytes = await odt();
const styles = strFromU8(unzipSync(bytes)['styles.xml']);
check('background', styles.includes('fo:background-color="#fff5c8"'), styles.slice(0, 200));
check('border', styles.includes('fo:border="1pt solid #0046a0"') && styles.includes('fo:padding="0.2cm"'),
styles.match(/fo:(border|padding)="[^"]*"/g));
check('watermark in the header', /<style:header>[\s\S]*PowerPlusWaterMarkObject/.test(styles));
// The draw: prefix has to be declared or LibreOffice drops the whole shape.
check('draw namespace declared', styles.includes('xmlns:draw='));
const back = importOdt(bytes).decor;
check('background round-trips', back.background === '#fff5c8', back.background);
check('border round-trips', back.border?.widthPt === 1 && back.border?.color === '#0046a0'
&& back.border?.paddingCm === 0.2, back.border);
check('watermark round-trips', back.watermark?.text === 'ENTWURF' && back.watermark?.angle === 45
&& back.watermark?.transparency === 50, back.watermark);
});
it('DOCX: background, w:pgBorders and the VML shape, and back', async () => {
const bytes = await docx();
const files = unzipSync(bytes);
const xml = strFromU8(files['word/document.xml']);
check('background', xml.includes('<w:background w:color="FFF5C8"/>'), xml.slice(0, 200));
check('switched on', strFromU8(files['word/settings.xml']).includes('<w:displayBackgroundShape/>'));
// w:sectPr has a fixed child order: pgBorders belongs after pgMar, never before
// the header references.
check('border after pgMar', /<w:pgMar\b[^>]*\/><w:pgBorders/.test(xml), xml.match(/<w:sectPr[\s\S]*?<\/w:sectPr>/)?.[0]);
const header = Object.keys(files).find((p) => /^word\/header\d*\.xml$/.test(p));
check('a header part carries the shape', !!header && strFromU8(files[header]).includes('PowerPlusWaterMarkObject'), header);
// Word has the WordArt preset built in, LibreOffice needs the definition to travel.
check('shapetype travels', !!header && strFromU8(files[header]).includes('<v:shapetype id="_x0000_t136"'));
const back = importDocx(bytes).decor;
check('background round-trips', back.background === '#fff5c8', back.background);
check('border round-trips', back.border?.widthPt === 1 && back.border?.color === '#0046a0', back.border);
check('watermark round-trips', back.watermark?.text === 'ENTWURF' && back.watermark?.angle === 45,
back.watermark);
});
it('an undecorated document writes none of it', async () => {
const styles = strFromU8(unzipSync(await buildOdt(doc, margins, 'portrait'))['styles.xml']);
check('no page background', !styles.includes('fo:background-color'), styles.match(/fo:background-color="[^"]*"/g));
check('no watermark', !styles.includes('PowerPlusWaterMarkObject'));
const files = unzipSync(await buildDocx(doc, margins, 'portrait'));
check('no w:background', !strFromU8(files['word/document.xml']).includes('<w:background'));
check('no w:pgBorders', !strFromU8(files['word/document.xml']).includes('<w:pgBorders'));
check('no header part minted', !Object.keys(files).some((p) => /^word\/header\d*\.xml$/.test(p)),
Object.keys(files));
});
});