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 @@
{#each MARGIN_FIELDS as axis}
- {t().toolbarExpanded.margins[axis]} + {mLabel(axis)}
onMarginKeydown(axis, e)} onblur={() => commitMarginInput(axis)} inputmode="decimal" - title={t().toolbarExpanded.marginField(t().toolbarExpanded.margins[axis])} + title={t().toolbarExpanded.marginField(mLabel(axis))} />
- -
{/each}
+
diff --git a/src/lib/components/ribbon/tabs/LayoutTab.svelte b/src/lib/components/ribbon/tabs/LayoutTab.svelte index a41eb40..b235f44 100644 --- a/src/lib/components/ribbon/tabs/LayoutTab.svelte +++ b/src/lib/components/ribbon/tabs/LayoutTab.svelte @@ -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')} {/if}
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');