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:
1 parent
61d7e20a0d
commit
321e410cd9
8 files changed
+67
-14
No files matched your search
+1
-1
@@ -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`)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user