feat: mirrored margins get a switch, so a new document can be a book

The inner/outer pair was read from a file, rendered and written back, but nothing
could turn it on — a book layout could only be inherited. Both margin UIs get a
checkbox beside the four fields, and while it is on the left/right fields read
Inner/Outer. A margin preset changes the values, not whether the setup is a book.
`withMirrored` keeps the key absent rather than false, so an unmirrored page setup
still compares equal. Driven headlessly: ticking it stores the flag, renames the
fields and moves an even page's text by the difference (1.5/3cm → 57px).
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:10 +02:00
1 parent 61d7e20a0d
commit 321e410cd9
8 files changed
+67 -14

No files matched your search

+1 -1
View File
@@ -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`)
+15 -6
View File
@@ -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 @@
<div class="margin-grid">
{#each MARGIN_FIELDS as axis}
<div class="margin-field">
<span class="margin-label">{t().toolbarExpanded.margins[axis]}</span>
<span class="margin-label">{mLabel(axis)}</span>
<div class="margin-input-wrap">
<input
type="text"
@@ -1501,15 +1502,15 @@
onkeydown={(e) => onMarginKeydown(axis, e)}
onblur={() => commitMarginInput(axis)}
inputmode="decimal"
title={t().toolbarExpanded.marginField(t().toolbarExpanded.margins[axis])}
title={t().toolbarExpanded.marginField(mLabel(axis))}
/>
<div class="margin-steppers">
<button class="margin-step" tabindex="-1" onclick={() => stepMargin(axis, MARGIN_STEP)} title={t().toolbarExpanded.increaseMargin(t().toolbarExpanded.margins[axis])} aria-label={t().toolbarExpanded.increaseMargin(t().toolbarExpanded.margins[axis])}>
<button class="margin-step" tabindex="-1" onclick={() => stepMargin(axis, MARGIN_STEP)} title={t().toolbarExpanded.increaseMargin(mLabel(axis))} aria-label={t().toolbarExpanded.increaseMargin(mLabel(axis))}>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1 5.5l3-3 3 3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button class="margin-step" tabindex="-1" onclick={() => stepMargin(axis, -MARGIN_STEP)} title={t().toolbarExpanded.decreaseMargin(t().toolbarExpanded.margins[axis])} aria-label={t().toolbarExpanded.decreaseMargin(t().toolbarExpanded.margins[axis])}>
<button class="margin-step" tabindex="-1" onclick={() => stepMargin(axis, -MARGIN_STEP)} title={t().toolbarExpanded.decreaseMargin(mLabel(axis))} aria-label={t().toolbarExpanded.decreaseMargin(mLabel(axis))}>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1 2.5l3 3 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1519,6 +1520,14 @@
</div>
{/each}
</div>
<label class="hf-firstpage-row" title={t().toolbarExpanded.mirrorMarginsHint}>
<input
type="checkbox"
checked={pageMargins.mirrored === true}
onchange={(e) => (pageMargins = withMirrored(pageMargins, e.currentTarget.checked))}
/>
<span>{t().toolbarExpanded.mirrorMargins}</span>
</label>
<div class="lh-section-label">{t().toolbarExpanded.headerFooter}</div>
<div class="hf-edit-row">
@@ -6,7 +6,7 @@
import { uniformBlockAttr } from '../../../utils/selectionFormat';
import { findColumns, DEFAULT_COLUMN_GAP_CM } from '../../../editor/extensions/columns';
import { PAGE_FORMAT_CM, type PageFormat } from '../../../storage/pageFormat';
import { DEFAULT_MARGINS, type PageMargins } from '../../../storage/pageMargins';
import { DEFAULT_MARGINS, marginAxisLabel, withMirrored, type PageMargins } from '../../../storage/pageMargins';
import type { Orientation } from '../../../storage/pageOrientation';
import { EMPTY_HF_SET, type HfSet, type HfZone } from '../../../storage/headerFooter';
import { DEFAULT_PAGE_NUMBERING, PAGE_NUM_FORMATS, clampPageStart, type PageNumbering } from '../../../storage/pageNumbering';
@@ -99,6 +99,9 @@
const fmtCm = (v: number) => (Math.round(v * 100) / 100).toString().replace('.', ',');
// Mirrored, the left/right fields are the inner/outer pair and say so.
const mLabel = (edge: (typeof EDGES)[number]) => t().toolbarExpanded.margins[marginAxisLabel(edge, pageMargins)];
function setMargin(edge: (typeof EDGES)[number], raw: string) {
const v = parseFloat(raw.replace(',', '.'));
if (isNaN(v)) return;
@@ -139,7 +142,7 @@
{#if isMenuOpen('margins')}
<div class="ribbon-menu margin-menu" use:anchored role="menu">
{#each MARGIN_PRESETS as p}
<button onclick={() => { closeMenu(); pageMargins = p.m; }}>
<button onclick={() => { closeMenu(); pageMargins = withMirrored(p.m, pageMargins.mirrored === true); }}>
{t().ribbon.marginPresets[p.key]}
<span class="menu-sub">{fmtCm(p.m.top)} / {fmtCm(p.m.left)} cm</span>
</button>
@@ -149,17 +152,25 @@
<div class="margin-grid">
{#each EDGES as edge}
<label class="margin-field">
<span>{t().toolbarExpanded.margins[edge]}</span>
<span>{mLabel(edge)}</span>
<input
type="text"
inputmode="decimal"
value={fmtCm(pageMargins[edge])}
title={t().toolbarExpanded.marginField(t().toolbarExpanded.margins[edge])}
title={t().toolbarExpanded.marginField(mLabel(edge))}
onchange={(e) => setMargin(edge, (e.currentTarget as HTMLInputElement).value)}
/>
</label>
{/each}
</div>
<label class="check-row" title={t().toolbarExpanded.mirrorMarginsHint}>
<input
type="checkbox"
checked={pageMargins.mirrored === true}
onchange={(e) => (pageMargins = withMirrored(pageMargins, e.currentTarget.checked))}
/>
{t().toolbarExpanded.mirrorMargins}
</label>
</div>
{/if}
</div>
+3 -1
View File
@@ -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',
+3 -1
View File
@@ -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',
+1 -1
View File
@@ -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
+15
View File
@@ -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
+14
View File
@@ -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');