diff --git a/CHANGELOG.md b/CHANGELOG.md
index 007a49a..207596a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -121,7 +121,7 @@ First public release, deployed on GitHub Pages.
- Page format picker (Layout panel): 15 Word/LibreOffice sizes — A3–A6, ISO B4–B6, JIS B4/B5, Letter, Legal, Tabloid, Executive, Folio, Statement — each showing its cm dimensions; drives the on-screen page, pagination, and ODT/DOCX/PDF export, and is detected & adopted on import
- Right-to-left pages (ODF `style:writing-mode="rl-tb"`, Word's `w:bidi`): the body's base direction, so a multi-column page fills its columns **from the right** as LibreOffice does, and bidi resolves a Hebrew or Arabic line the way the file means it. Round-trips through both formats
- Per-paragraph text direction (Paragraph dialog ▸ Text direction, where LibreOffice keeps it): a single quoted Hebrew or Arabic paragraph turns right-to-left inside a left-to-right document — and back. Round-trips as the block's own `style:writing-mode` / `w:bidi`, in table cells and list items too; the direction the page already has is inheritance, not formatting, so neither importer stamps it on every block
-- Mirrored page margins (ODF `style:page-usage="mirrored"`, Word's `w:mirrorMargins`): the declared left/right are the inner/outer pair, and an even (left-hand) page swaps them — body text and the header/footer band alike. Round-trips through both formats
+- Mirrored page margins (ODF `style:page-usage="mirrored"`, Word's `w:mirrorMargins`): the declared left/right are the inner/outer pair, and an even (left-hand) page swaps them — body text and the header/footer band alike. Switched on where the margins are set (the ribbon's Margins menu and the Layout panel), which renames the two fields Inner/Outer; a margin preset changes the values, not whether the setup is a book. Round-trips through both formats
- Multi-column (newspaper) layout: 1–3 columns with adjustable gap, applied to the whole document (no selection) or to selected paragraphs, Word-style — text fills column 1 to the page bottom, then column 2, and flows across pages with mid-paragraph line breaks; a section followed by other content balances its columns. Round-trips to ODF `text:section`/`style:columns` and DOCX continuous sections with `w:cols`
- Page margins per section: a section's own `w:pgMar` / ODF page layout is read, rendered and written back, and an ODF page style that hands over to another (`style:next-style-name`, the title-page idiom) gives its layout to the section's first page and the successor's to the rest. The ruler and the header/footer layer still show the document's own pair
- Headers & footers: page-number / page-count fields, configurable edge distances, and variants — different first page and different odd & even pages (edit a page's zone directly; blank first/even zones supported). Press Enter to add blank lines that grow a zone into the page while body text reflows so the page break sits above the footer / below the header, never overlapping. Inline images/logos can be inserted in a zone. Round-trips to ODF (`style:header-first`/`-left`, `draw:frame`) and DOCX (`w:titlePg`/`w:evenAndOddHeaders`, `ImageRun`)
diff --git a/src/lib/components/ToolbarExpanded.svelte b/src/lib/components/ToolbarExpanded.svelte
index c1dbe3f..5966c0a 100644
--- a/src/lib/components/ToolbarExpanded.svelte
+++ b/src/lib/components/ToolbarExpanded.svelte
@@ -19,7 +19,7 @@
queryLocalFontsIfAllowed,
supportsLocalFontAccess,
} from '../utils/fontDetect';
- import { DEFAULT_MARGINS, cmToPx, type PageMargins } from '../storage/pageMargins';
+ import { DEFAULT_MARGINS, cmToPx, marginAxisLabel, withMirrored, type MarginAxis, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { pageDimsCm, PAGE_FORMAT_CM, type PageFormat } from '../storage/pageFormat';
import { DEFAULT_HF_DISTANCES, clampHfDistance, type HfDistances } from '../storage/headerFooter';
@@ -590,8 +590,9 @@
}
// --- Page layout / margins (cm) ---
- type MarginAxis = 'top' | 'bottom' | 'left' | 'right';
const MARGIN_FIELDS: MarginAxis[] = ['top', 'bottom', 'left', 'right'];
+ // Mirrored, the left/right fields are the inner/outer pair and say so.
+ const mLabel = (axis: MarginAxis) => t().toolbarExpanded.margins[marginAxisLabel(axis, pageMargins)];
const MARGIN_STEP = 0.1;
const MARGIN_MIN = 0;
const MARGIN_MAX = 10;
@@ -1492,7 +1493,7 @@
diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts
index 1fd755d..0b76061 100644
--- a/src/lib/i18n/locales/de.ts
+++ b/src/lib/i18n/locales/de.ts
@@ -265,13 +265,15 @@ const de: Messages = {
portrait: 'Hochformat',
landscape: 'Querformat',
pageMargins: 'Seitenränder (cm)',
- margins: { top: 'Oben', bottom: 'Unten', left: 'Links', right: 'Rechts' },
+ margins: { top: 'Oben', bottom: 'Unten', left: 'Links', right: 'Rechts', inner: 'Innen', outer: 'Außen' },
marginField: (label) => `Rand ${label.toLowerCase()} (cm)`,
increaseMargin: (label) => `Rand ${label.toLowerCase()} vergrößern`,
decreaseMargin: (label) => `Rand ${label.toLowerCase()} verkleinern`,
headerFooter: 'Kopf- & Fußzeile',
editHeader: 'Kopfzeile bearbeiten',
editFooter: 'Fußzeile bearbeiten',
+ mirrorMargins: 'Gespiegelte Ränder',
+ mirrorMarginsHint: 'Linken und rechten Rand auf geraden Seiten tauschen, damit der innere zur Bindung zeigt',
differentFirstPage: 'Erste Seite anders',
differentFirstPageHint: 'Der ersten Seite eine eigene Kopf- und Fußzeile geben',
differentOddEven: 'Gerade & ungerade Seiten anders',
diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts
index 613de1b..4336482 100644
--- a/src/lib/i18n/locales/en.ts
+++ b/src/lib/i18n/locales/en.ts
@@ -271,13 +271,15 @@ const en = {
portrait: 'Portrait',
landscape: 'Landscape',
pageMargins: 'Page margins (cm)',
- margins: { top: 'Top', bottom: 'Bottom', left: 'Left', right: 'Right' },
+ margins: { top: 'Top', bottom: 'Bottom', left: 'Left', right: 'Right', inner: 'Inner', outer: 'Outer' },
marginField: (label: string) => `${label} margin (cm)`,
increaseMargin: (label: string) => `Increase ${label.toLowerCase()} margin`,
decreaseMargin: (label: string) => `Decrease ${label.toLowerCase()} margin`,
headerFooter: 'Header & footer',
editHeader: 'Edit header',
editFooter: 'Edit footer',
+ mirrorMargins: 'Mirrored margins',
+ mirrorMarginsHint: 'Swap the left and right margin on even pages, so the inner one faces the binding',
differentFirstPage: 'Different first page',
differentFirstPageHint: 'Give the first page its own header and footer',
differentOddEven: 'Different odd & even pages',
diff --git a/src/lib/storage/CLAUDE.md b/src/lib/storage/CLAUDE.md
index 891a998..9d8db65 100644
--- a/src/lib/storage/CLAUDE.md
+++ b/src/lib/storage/CLAUDE.md
@@ -5,7 +5,7 @@ password-protected: the protection is on the saved file, not on the browser copy
## Page layout settings
-- **`pageMargins.ts`** — `PageMargins` in **cm** (default `{ top: 2, bottom: 2, left: 2, right: 2 }` — LibreOffice's; `fitMargins` only caps an imported pair at what leaves the page a 1cm strip of text). `applyMarginVars` sets `--user-margin-*` (px) on `:root`; `PX_PER_CM = 96/2.54`. Optional `mirrored` (ODF `style:page-usage="mirrored"`, Word `w:mirrorMargins`) makes left/right the **inner/outer** pair, which an even page swaps: `Editor.svelte` publishes the difference per section as `--pb-section-mirror` (a cover between mirrored pages mirrors nothing), `pageBreaks.ts` insets an even page's blocks by it and `HeaderFooterLayer.svelte` moves the band. The key is **absent**, never `false`, when off — margins are compared whole in the round-trip tests.
+- **`pageMargins.ts`** — `PageMargins` in **cm** (default `{ top: 2, bottom: 2, left: 2, right: 2 }` — LibreOffice's; `fitMargins` only caps an imported pair at what leaves the page a 1cm strip of text). `applyMarginVars` sets `--user-margin-*` (px) on `:root`; `PX_PER_CM = 96/2.54`. Optional `mirrored` (ODF `style:page-usage="mirrored"`, Word `w:mirrorMargins`) makes left/right the **inner/outer** pair, which an even page swaps: `Editor.svelte` publishes the difference per section as `--pb-section-mirror` (a cover between mirrored pages mirrors nothing), `pageBreaks.ts` insets an even page's blocks by it and `HeaderFooterLayer.svelte` moves the band. The key is **absent**, never `false`, when off — margins are compared whole in the round-trip tests, which is what `withMirrored` is for; `marginAxisLabel` gives both margin UIs the Inner/Outer field names that go with it.
- **`tabInterval.ts`** — the document's default tab interval in **cm** (`DEFAULT_TAB_INTERVAL_CM = 1.25`, LibreOffice's for a new document; clamped 0.05–10). `applyTabIntervalVar` sets `--tab-interval`, which is `.tiptap`'s `tab-size`. A file that declares none falls back to its format's own value, neither of them ours: `ODF_IMPLIED_TAB_CM` 2cm (measured against LibreOffice), `DOCX_IMPLIED_TAB_CM` 1.27cm — so both exports always write the value out.
- **`spacingModel.ts`** — how the gap between two blocks is measured: `'add'` (LibreOffice's
own — space-below **plus** space-above) or `'max'` (the larger of the two, what it uses for a
diff --git a/src/lib/storage/pageMargins.ts b/src/lib/storage/pageMargins.ts
index 9dc8dc2..27af1e7 100644
--- a/src/lib/storage/pageMargins.ts
+++ b/src/lib/storage/pageMargins.ts
@@ -4,6 +4,21 @@ export type PageMargins = {
top: number; bottom: number; left: number; right: number; mirrored?: boolean;
};
+export type MarginAxis = 'top' | 'bottom' | 'left' | 'right';
+
+// The flag is absent, never false, when off: margins are compared whole, so a stored
+// `mirrored: false` would read as a different page setup than an unmirrored one.
+export function withMirrored(m: PageMargins, on: boolean): PageMargins {
+ const { mirrored: _was, ...rest } = m;
+ return on ? { ...rest, mirrored: true } : rest;
+}
+
+// Mirrored, the left/right pair is the inner/outer one — the label the field carries.
+export function marginAxisLabel(axis: MarginAxis, m: PageMargins): MarginAxis | 'inner' | 'outer' {
+ if (!m.mirrored || (axis !== 'left' && axis !== 'right')) return axis;
+ return axis === 'left' ? 'inner' : 'outer';
+}
+
const KEY = 'edentext-page-margins';
// LibreOffice Writer's default page margins (Word uses 2.54cm all round). Only a new
diff --git a/tests/unit/mirror-margins.test.ts b/tests/unit/mirror-margins.test.ts
index 90adb12..5d888d1 100644
--- a/tests/unit/mirror-margins.test.ts
+++ b/tests/unit/mirror-margins.test.ts
@@ -4,6 +4,7 @@ import { importOdt } from '../../src/lib/import/odt';
import { importDocx } from '../../src/lib/import/docx';
import { buildOdt } from '../../src/lib/export/odt';
import { buildDocx } from '../../src/lib/export/docx';
+import { marginAxisLabel, withMirrored } from '../../src/lib/storage/pageMargins';
// ODF style:page-usage="mirrored" / Word w:mirrorMargins: the declared left and right
// are the inner/outer pair, so an even (left-hand) page swaps them.
@@ -62,6 +63,19 @@ describe('mirrored page margins', () => {
expect(importDocx(bytes).margins).toMatchObject({ left: 1.5, right: 3, mirrored: true });
});
+ it('is switched on and off without leaving a false behind', async () => {
+ const plain = { top: 2, bottom: 2, left: 1.5, right: 3 };
+ const on = withMirrored(plain, true);
+ expect(on).toEqual({ ...plain, mirrored: true });
+ expect(withMirrored(on, false)).toEqual(plain);
+ expect('mirrored' in withMirrored(on, false)).toBe(false);
+ // The pair the fields name follows the switch; top and bottom never move.
+ expect([marginAxisLabel('left', on), marginAxisLabel('right', on), marginAxisLabel('top', on)])
+ .toEqual(['inner', 'outer', 'top']);
+ expect(marginAxisLabel('left', plain)).toBe('left');
+ expect(importOdt(await buildOdt(doc, on)).margins?.mirrored).toBe(true);
+ });
+
it('leaves an unmirrored DOCX export alone', async () => {
const bytes = await buildDocx(doc, { top: 2, bottom: 2, left: 1.5, right: 3 });
expect(strFromU8(unzipSync(bytes)['word/settings.xml'])).not.toContain('mirrorMargins');