diff --git a/CHANGELOG.md b/CHANGELOG.md index 207596a..6441272 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,6 +123,7 @@ First public release, deployed on GitHub Pages. - 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. 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` +- A section that opens on a **right or left page** (ODF `style:page-usage`, Word's odd/even-page section break, in the ribbon's Breaks menu): a blank page goes in where the flow would open it on the other side, which is how a book starts every chapter recto. Read, rendered and written back in both formats - 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`) - Zoom (20–300 %) diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 45af5de..b152ecf 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -309,6 +309,11 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor'; let sectionNumStarts = $derived([pageNumbering.start, ...extraHfSections.map((s) => s.pageNumberStart ?? 'x')].join(',')); + // The side each section must open on ('x' = wherever it falls): a blank page goes + // before one whose flow would open it on the other. + let sectionStartsOn = $derived(['x', + ...extraHfSections.map((s) => (s.startsOn === 'odd' ? 'o' : s.startsOn === 'even' ? 'e' : 'x'))].join(',')); + // The number a page shows, and from it its side: a left page is an even-numbered one. let printedNumberAt = $derived.by(() => { const starts = [pageNumbering.start, ...extraHfSections.map((s) => s.pageNumberStart ?? null)]; @@ -320,6 +325,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor'; $effect(() => { const s = document.documentElement.style; s.setProperty('--pb-section-numstart', sectionNumStarts); + s.setProperty('--pb-section-startson', sectionStartsOn); s.setProperty('--pb-section-inset', sectionInset); s.setProperty('--pb-space-at-page-start', spacingAtPageStart ? '1' : '0'); s.setProperty('--pb-section-mirror', sectionMirror); diff --git a/src/lib/components/ribbon/tabs/LayoutTab.svelte b/src/lib/components/ribbon/tabs/LayoutTab.svelte index b235f44..f072252 100644 --- a/src/lib/components/ribbon/tabs/LayoutTab.svelte +++ b/src/lib/components/ribbon/tabs/LayoutTab.svelte @@ -8,7 +8,7 @@ import { PAGE_FORMAT_CM, type PageFormat } from '../../../storage/pageFormat'; 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 { EMPTY_HF_SET, type HfSet, type HfZone, type PageSide } 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 { DEFAULT_LINE_NUMBERING, type LineNumbering } from '../../../storage/lineNumbering'; @@ -82,6 +82,11 @@ extraHfSections = out; } + // The side this section must open on, if it must (Word's Layout ▸ Section start). + let sectionStartsOn = $derived( + currentSection > 0 ? extraHfSections[currentSection - 1]?.startsOn ?? null : null, + ); + // Where this section restarts the numbering, if it does (Word's "Start at"). let sectionPageStart = $derived( currentSection > 0 ? extraHfSections[currentSection - 1]?.pageNumberStart ?? null : null, @@ -273,6 +278,20 @@ {t().ribbon.sectionBreak} {t().ribbon.sectionBreakHint} + {#if currentSection > 0} +
+
{t().ribbon.thisSection}
+ {#each [['odd', t().ribbon.startsOnOdd], ['even', t().ribbon.startsOnEven]] as [side, label]} + + {/each} + {/if} {/if} diff --git a/src/lib/editor/extensions/pageBreaks.ts b/src/lib/editor/extensions/pageBreaks.ts index 6cebb1e..bede610 100644 --- a/src/lib/editor/extensions/pageBreaks.ts +++ b/src/lib/editor/extensions/pageBreaks.ts @@ -1172,6 +1172,9 @@ export const PageBreaks = Extension.create({ // document's own start: the side a page is on follows its number, not its sheet. const numStarts = csRoot.getPropertyValue('--pb-section-numstart').split(',') .map((v) => (Number.isFinite(Number(v)) && v.trim() !== '' ? Number(v) : null)); + // The side a section must open on ('o'/'e'), else 'x'. + const sides = csRoot.getPropertyValue('--pb-section-startson').split(',').map((v) => v.trim()); + const sideAt = (i: number) => (sides[i] === 'o' ? 'odd' : sides[i] === 'e' ? 'even' : null); const mirrors = csRoot.getPropertyValue('--pb-section-mirror').split(',') .map((g) => g.split('|').map(Number)) .filter((g) => g.length === 2 && g.every(Number.isFinite)); @@ -1252,6 +1255,18 @@ export const PageBreaks = Extension.create({ sectionIndex++; sectionFirstPage = pushed ? page + 1 : page; sectionFirstPages[sectionIndex] = sectionFirstPage; + // A section that must open on a right or left page takes the blank page + // before it, the way both word processors insert one — never for the + // document's own first page, which is a right page whatever it says. + const side = i > 0 ? sideAt(sectionIndex) : null; + if (side) { + const numberOf = (pg: number) => printedPageNumber(pg, sectionIndex, numStarts, + (j) => (j === sectionIndex ? pg : sectionFirstPages[j] ?? 1)); + if ((isLeftPage(numberOf(sectionFirstPage)) ? 'even' : 'odd') !== side) { + sectionFirstPage++; + sectionFirstPages[sectionIndex] = sectionFirstPage; + } + } // Its paper governs from its first page on. Setting it here can only move // pages *below* that one, so the page just resolved stays valid. grid.setFrom(sectionFirstPage, paperAt(sectionIndex)); @@ -1345,13 +1360,17 @@ export const PageBreaks = Extension.create({ // A manual page break forces the block to the next page's top (never the first // leaf, so no leading blank page); once settled at a page top the guard stops. // A forced block that then overflows is split by the normal logic next pass. - const forced = !!leaf.forceBreakBefore && i > 0 && effectiveTop > contentStart + 0.5; + // A section held back for its own side skips the sheets between: its first + // page is settled above, so the spacer reaches straight for that one. + const skipTo = leaf.sectionStart && sectionFirstPage > page ? sectionFirstPage : 0; + const forced = skipTo > 0 || (!!leaf.forceBreakBefore && i > 0 && effectiveTop > contentStart + 0.5); if (forced) { - // The next page's own content start: a section beginning here brings its - // first-page header with it. - const nextTop = page + 1 === sectionFirstPage ? topFirst : topRest; - const target = pageContentStart(page + 1, nextTop, grid); + // The target page's own content start: a section beginning there brings + // its first-page header with it. + const targetPage = skipTo || page + 1; + const nextTop = targetPage === sectionFirstPage ? topFirst : topRest; + const target = pageContentStart(targetPage, nextTop, grid); const { docPos, row } = leafSpacer(leaf); breaks.push({ height: target - effectiveTop, diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 8be0e29..3535e6f 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -2874,8 +2874,12 @@ export async function buildDocx( // its header set carries titlePg; later groups of the set must not repeat it. ...(setAt(g.section).differentFirstPage && g.section < hfSets.length && groups.findIndex((x) => x.section === g.section) === i ? { titlePage: true } : {}), - ...(i > 0 && !(setAt(g.section).pageNumberStart != null && groups.findIndex((x) => x.section === g.section) === i) - ? { type: SectionType.CONTINUOUS } : {}), + // A section that must open on a right or left page says so here; Word inserts + // the blank page for it, as LibreOffice does for style:page-usage. + ...(i > 0 && groups.findIndex((x) => x.section === g.section) === i && setAt(g.section).startsOn + ? { type: setAt(g.section).startsOn === 'odd' ? SectionType.ODD_PAGE : SectionType.EVEN_PAGE } + : i > 0 && !(setAt(g.section).pageNumberStart != null && groups.findIndex((x) => x.section === g.section) === i) + ? { type: SectionType.CONTINUOUS } : {}), ...(g.columns ? { column: { count: g.columns.count, space: cmToTwip(g.columns.gapCm), equalWidth: true } } : {}), diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 92aaed9..ef0dd47 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -5487,10 +5487,14 @@ function applySectionMasterPages(odtBytes: Uint8Array, sets: HfSet[], pageCount: const dist = set.distances ?? null; const distTop = dist && (set.header || set.headerFirst || set.headerEven) ? dist.header : null; const distBottom = dist && (set.footer || set.footerFirst || set.footerEven) ? dist.footer : null; - if ((!m && !paper && !numFormat && distTop == null && distBottom == null) || !layoutXml) return layout; + // The side the section opens on: page one is a right page whatever it says, so only + // a later section carries it. + const side = index > 0 && set.startsOn ? (set.startsOn === 'odd' ? 'right' : 'left') : null; + if ((!m && !paper && !numFormat && distTop == null && distBottom == null && !side) || !layoutXml) return layout; const name = `${layout}Sec${index + 1}`; layouts.push(layoutXml - .replace(`style:name="${layout}"`, `style:name="${name}"`) + .replace(/\s*style:page-usage="[^"]*"/, '') + .replace(`style:name="${layout}"`, `style:name="${name}"${side ? ` style:page-usage="${side}"` : ''}`) .replace(/]*>/, (props) => { // The margins are a shift, so whatever the header/footer pass folded into them // survives; the paper is set outright. diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index 0b76061..c3dc5b5 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -982,6 +982,9 @@ const de: Messages = { insertLeft: 'Links einfügen', insertRight: 'Rechts einfügen', sectionBreak: 'Abschnittsumbruch', + startsOnOdd: 'Auf rechter Seite beginnen', + startsOnEven: 'Auf linker Seite beginnen', + startsOnHint: 'Eine Leerseite einschieben, wenn der Abschnitt sonst auf der anderen Seite beginnt', sectionBreakHint: 'Beginnt eine eigene Kopf- und Fußzeile', indentLeft: 'Einzug links', indentRight: 'Einzug rechts', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 4336482..3366617 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -988,6 +988,9 @@ const en = { insertLeft: 'Insert left', insertRight: 'Insert right', sectionBreak: 'Section break', + startsOnOdd: 'Start on a right page', + startsOnEven: 'Start on a left page', + startsOnHint: 'Take a blank page where the flow would open this section on the other side', sectionBreakHint: 'Starts its own header and footer', indentLeft: 'Indent left', indentRight: 'Indent right', diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index f899b2b..79c7537 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -32,7 +32,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt( - **Content controls** (`inlineChildren`, DOCX): a `w:sdt` inside a paragraph — Word wraps every citation and bibliography entry in one — is walked through to its `w:sdtContent`, or the runs inside it never reach the paragraph (a fixture measured against LibreOffice lost every `[7]`-style reference and broke its lines one page short). The exception is one tagged `edentext-placeholder`: it survives whole as a `placeholderField` node, its export-minted gray stripped so the presentation color never becomes a mark. ODF's `text:placeholder` becomes the same node, the shown text's `<>`/`‹›` brackets stripped. - **Formulas** (`convertFormulaFrame`): a `` whose `` resolves to a sub-document with a `math:math` root (or holds one inline) → a `formula` node. Our own `` is preferred; LibreOffice strips it, so the MathML is parsed back to LaTeX instead. **`display` comes from the paragraph** (`aloneInParagraph`), not the object — LibreOffice writes `display="block"` on every formula it re-saves. DOCX: `m:oMath`/`m:oMathPara` sit beside the `w:r` runs in their own namespace, so `convertInline` probes them ahead of its `namespaceURI !== W` guard (that guard silently dropped every Word formula). See `docs/architecture/formulas.md`. - **Bookmarks & cross-references**: `text:bookmark-start`/`-end` (ODF) and `w:bookmarkStart`/`End` (DOCX) become a `bookmark` **mark** on the text they cover; the open ranges live in `Ctx` because Word writes them beside paragraphs as well as beside runs, and a range may close in a later paragraph. A **point** bookmark has no range to mark and is dropped, `_GoBack` with it. `text:bookmark-ref` and `REF`/`PAGEREF` fields (the `fldChar` machine and `w:fldSimple` alike, including a field Word left unresolved) become `crossRef` nodes carrying the producer's cached text. A `w:hyperlink w:anchor` with no `r:id` is an internal link → `#name`. See `docs/architecture/formatting.md`. -- **Page geometry is the *body's* master page**, not the one named `Standard`: a pre-pass over the body's paragraphs (`dominantMasterPage`) counts the blocks each `style:master-page-name` governs and geometry comes from the winner — **before** the walk, since the text width it yields is what a table's margins, an inline image's fit and the tab-stop suppression measure against (17.0cm against a real 14.7 on one file when it was elected after). A document whose Standard master no paragraph references would otherwise take margins — and a footer band — no page of it has. **Naming a master breaks the page** (LibreOffice's "page break with page style") — probed: even a style naming the master the page already uses breaks, which is how a book starts each chapter fresh, so only the document's first block is exempt. The *section* (its own header/footer set) starts only where the master really changes. An empty `style:master-page-name=""` is LibreOffice's explicit "no change" and ends the parent-chain walk, so an ancestor's master can't break every child style. `fo:break-after="page"` becomes the next block's `breakBefore`, the only break the editor stores. A master with a `style:next-style-name` is itself the different-first-page flag: its own (possibly absent) zones govern page one — **unless the two name each other**. A `next-style-name` cycle is LibreOffice's Left Page / Right Page, one mirrored setup rather than a hand-over: the right master's geometry governs, the left master's zones become the even variant, and `style:page-usage="left"`/`"right"` marks the margins `mirrored` **only where that twin really exists** — on a single-sided layout it means a page that starts on the right, and swapping its pair drew an index 9mm off. Reading it as a hand-over cost a book-style body its running head on every second page and its inner/outer margins with it. +- **Page geometry is the *body's* master page**, not the one named `Standard`: a pre-pass over the body's paragraphs (`dominantMasterPage`) counts the blocks each `style:master-page-name` governs and geometry comes from the winner — **before** the walk, since the text width it yields is what a table's margins, an inline image's fit and the tab-stop suppression measure against (17.0cm against a real 14.7 on one file when it was elected after). A document whose Standard master no paragraph references would otherwise take margins — and a footer band — no page of it has. **Naming a master breaks the page** (LibreOffice's "page break with page style") — probed: even a style naming the master the page already uses breaks, which is how a book starts each chapter fresh, so only the document's first block is exempt. The *section* (its own header/footer set) starts only where the master really changes. An empty `style:master-page-name=""` is LibreOffice's explicit "no change" and ends the parent-chain walk, so an ancestor's master can't break every child style. `fo:break-after="page"` becomes the next block's `breakBefore`, the only break the editor stores. A master with a `style:next-style-name` is itself the different-first-page flag: its own (possibly absent) zones govern page one — **unless the two name each other**. A `next-style-name` cycle is LibreOffice's Left Page / Right Page, one mirrored setup rather than a hand-over: the right master's geometry governs, the left master's zones become the even variant, and `style:page-usage="left"`/`"right"` marks the margins `mirrored` **only where that twin really exists** — on a single-sided layout it means a page that starts on the right (`HfSet.startsOn`, which takes a blank page rather than the pair), and swapping its pair drew an index 9mm off. Reading it as a hand-over cost a book-style body its running head on every second page and its inner/outer margins with it. - **A page-anchored frame** (`text:anchor-type="page"`) is out of the flow: it rides a paragraph of its own that collapses to nothing, and the node view places it from its page's corner (`anchorPage`, `image.ts`) — that paragraph must stay `position: static` (`editor.css`, `!important` over the line-height rule's own `position: relative`), or it becomes the frame's containing block instead of the page and offsets it by a page margin. Behind the text by default (`style:run-through="background"`); a cover page's own graphic instead declares `"foreground"` (`inFront` attr) to sit in front and hide what the page hoists behind it (a redundant logo, a title paragraph the graphic already carries). In a cell it falls back to an ordinary floating frame. - **A header/footer zone is fitted to its schema** (`fitZoneSchema`): the zone editor has fewer marks and nodes than the body, and one it doesn't know makes `generateHTML` throw — which renders the whole zone blank. Unknown marks are dropped, unknown nodes leave their text. - **An as-char text box is placed by its paragraph**, which is where its alignment stays — the box keeps only the `style:horizontal-pos` a band-wrapped frame declares of its own. Its `fo:padding` is read too — the editor's own 0.15cm ring is only the default. diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index c374316..c816dcc 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -2763,6 +2763,14 @@ function sectionStartsNewPage(sectPr: Element | null): boolean { return val !== 'continuous' && val !== 'nextColumn'; } +// w:type oddPage/evenPage: the section opens on that side, taking a blank page where +// the flow would open it on the other one. +function sectionStartSide(sectPr: Element | null): 'odd' | 'even' | null { + const t = fc(sectPr, 'type'); + const val = t ? wVal(t) : null; + return val === 'oddPage' ? 'odd' : val === 'evenPage' ? 'even' : null; +} + // A sectPr's w:cols → columns attrs when it declares more than one column. function sectPrColumns(sectPr: Element | null, ctx: Ctx): { count: number; gapCm: number } | null { const cols = fc(sectPr, 'cols'); @@ -3150,6 +3158,7 @@ function sectionHfSets( // w:fmt is per section too — a roman front matter before a decimal body. const pgFmt = DOCX_PAGE_NUM_FORMAT[fc(sect, 'pgNumType')?.getAttributeNS(W, 'fmt') ?? ''] ?? '1'; out.push({ + startsOn: sectionStartSide(sect), margins: sectMargins(sect), distances: sameDist(dist, docDist) ? null : dist, pageNumberStart: out.length && pgStart != null ? clampPageStart(pgStart) : null, diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 4370e71..b66d7dd 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -938,6 +938,9 @@ function hfSetOfMasterPage( distancesFirst: firstDist && !sameDist(firstDist, restDist) ? firstDist : null, margins: rest, marginsFirst: hf.restPage && !hf.mirrorPair ? own : null, + // The section's own layout is what says which side it opens on, even where a + // successor governs the pages after it. + startsOn: ctx.resolver.pageStartSide(name), pageNumberFormat: numFormat !== docNumFormat ? numFormat : null, format: paper && doc && paper.format !== doc.format ? paper.format : null, orientation: paper && doc && paper.orientation !== doc.orientation ? paper.orientation : null, diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index 4565fb7..ee657c6 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -765,6 +765,15 @@ export class StyleResolver { }; } + // The side a page style must open on: ODF's style:page-usage right/left, where the + // master is not one half of a mirrored pair — there the two alternate by themselves + // and neither forces a blank page. + pageStartSide(pageName: string | null = null): 'odd' | 'even' | null { + if (this.masterPageHF(pageName).mirrorPair) return null; + const usage = this.pageLayoutEl(pageName)?.getAttributeNS(NS.style, 'page-usage'); + return usage === 'right' ? 'odd' : usage === 'left' ? 'even' : null; + } + private pageUsage(mp: Element | null): string { const name = mp?.getAttributeNS(NS.style, 'name') ?? null; return name ? this.pageLayoutEl(name)?.getAttributeNS(NS.style, 'page-usage') ?? '' : ''; diff --git a/src/lib/storage/CLAUDE.md b/src/lib/storage/CLAUDE.md index ce797c2..19d0331 100644 --- a/src/lib/storage/CLAUDE.md +++ b/src/lib/storage/CLAUDE.md @@ -19,7 +19,7 @@ password-protected: the protection is on the saved file, not on the browser copy On is the default both products write, and `pageBreaks.ts` reads it as `--pb-space-at-page-start`. - **`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`. A **later section** can restart the count (`HfSet.pageNumberStart`): ODF writes `style:page-number` on the same paragraph that switches master page, Word a `w:pgNumType w:start` — and that section is then exported as a page-starting break, since both word processors ignore a start on a continuous one (probed: LibreOffice drops it). +- **`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`. A section can also demand the **side it opens on** (`HfSet.startsOn`, ODF `style:page-usage` right/left — read only where the master is no half of a mirrored pair — Word's `w:type` oddPage/evenPage): `pageBreaks.ts` holds its first page back by one where the printed number would land it on the other side, which is the blank page both word processors insert. A **later section** can restart the count (`HfSet.pageNumberStart`): ODF writes `style:page-number` on the same paragraph that switches master page, Word a `w:pgNumType w:start` — and that section is then exported as a page-starting break, since both word processors ignore a start on a continuous one (probed: LibreOffice drops it). - **`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. A **paragraph** can only opt *out* (`noHyphenation`, pageBreak.ts): Word has no per-paragraph "on", and both importers drop the flag under a document that does not hyphenate, where it says what is already true. - **`wordCompletion.ts`** / **`.svelte.ts`** — the word-completion settings *and* the collected words (`edentext-word-completion`), LibreOffice's defaults: on, no appended space, words from eight letters, 1000 of them. The list outlives the document that filled it, as LibreOffice's profile copy does; a reactive singleton, since the extension both reads it and adds to it on every keystroke while the AutoCorrect dialog edits it. - **`autoText.ts`** / **`.svelte.ts`** — the AutoText library (`edentext-autotext`, the key removed when empty): name, shortcut and the entry's nodes (an older entry's HTML string is read as the content it is). App-level, not document-level, so no exporter looks at it. diff --git a/src/lib/storage/headerFooter.ts b/src/lib/storage/headerFooter.ts index 3be0ee1..46dc476 100644 --- a/src/lib/storage/headerFooter.ts +++ b/src/lib/storage/headerFooter.ts @@ -43,14 +43,20 @@ export type HfSet = { // document's; `distancesFirst` is its first page's where that hands over. distances?: HfDistances | null; distancesFirst?: HfDistances | null; + // The side the section must open on (ODF `style:page-usage` right/left on its page + // layout, Word's `w:type` oddPage/evenPage): where the flow would open it on the + // other one, a blank page goes before it. null = wherever it falls. + startsOn?: PageSide | null; }; +export type PageSide = 'odd' | 'even'; + export const EMPTY_HF_SET: HfSet = { header: null, footer: null, headerFirst: null, footerFirst: null, differentFirstPage: false, headerEven: null, footerEven: null, differentOddEven: false, margins: null, marginsFirst: null, format: null, orientation: null, - distances: null, distancesFirst: null, + distances: null, distancesFirst: null, startsOn: null, pageNumberStart: null, pageNumberFormat: null, }; diff --git a/tests/unit/section-start-side.test.ts b/tests/unit/section-start-side.test.ts new file mode 100644 index 0000000..d1c33b5 --- /dev/null +++ b/tests/unit/section-start-side.test.ts @@ -0,0 +1,51 @@ +// A section can demand the side it opens on (ODF style:page-usage right/left on its +// page layout, Word's w:type oddPage/evenPage): where the flow would open it on the +// other one, both word processors insert a blank page. +import { describe, it, expect } from 'vitest'; +import { unzipSync, strFromU8 } from 'fflate'; +import { buildOdt } from '../../src/lib/export/odt'; +import { buildDocx } from '../../src/lib/export/docx'; +import { importOdt } from '../../src/lib/import/odt'; +import { importDocx } from '../../src/lib/import/docx'; +import { EMPTY_HF_SET } from '../../src/lib/storage/headerFooter'; + +type N = any; + +const P = (text: string, attrs?: N): N => + ({ type: 'paragraph', ...(attrs ? { attrs } : {}), content: [{ type: 'text', text }] }); + +const doc: N = { + type: 'doc', + content: [P('front'), P('chapter', { sectionBreak: true, breakBefore: 'page' })], +}; +const zone: N = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'z' }] }] }; +const hf = { + header: zone, footer: null, pageCount: 2, + sections: [{ ...EMPTY_HF_SET, header: zone }, { ...EMPTY_HF_SET, header: zone, startsOn: 'odd' as const }], +}; +const margins = { top: 2, bottom: 2, left: 2, right: 2 }; +const sides = (r: N) => (r.hfSections ?? []).map((s: N) => s.startsOn ?? null); + +describe('the side a section opens on', () => { + it('round-trips through ODF as style:page-usage', async () => { + const bytes = await buildOdt(doc, margins, 'portrait', hf as never); + expect(strFromU8(unzipSync(bytes)['styles.xml'])).toContain('style:page-usage="right"'); + expect(sides(importOdt(bytes))).toEqual([null, 'odd']); + }); + + it('round-trips through DOCX as w:type', async () => { + const bytes = await buildDocx(doc, margins, 'portrait', hf as never); + expect(strFromU8(unzipSync(bytes)['word/document.xml'])).toContain(' { + const plain = { ...hf, sections: [hf.sections[0], { ...EMPTY_HF_SET, header: zone }] }; + const odt = await buildOdt(doc, margins, 'portrait', plain as never); + expect(strFromU8(unzipSync(odt)['styles.xml'])).not.toContain('page-usage="right"'); + expect(sides(importOdt(odt))).toEqual([null, null]); + const docx = await buildDocx(doc, margins, 'portrait', plain as never); + expect(strFromU8(unzipSync(docx)['word/document.xml'])).not.toContain('w:val="oddPage"'); + expect(sides(importDocx(docx))).toEqual([null, null]); + }); +});