feat: a section can open on a right or left page, blank page and all
ODF says it with style:page-usage right/left on the section's page layout (read only where the master is no half of a mirrored pair, which alternates by itself), Word with a w:type oddPage/evenPage section break; both insert the blank page the side needs, and so does pagination now — the printed number decides the side. In the ribbon's Breaks menu under "This section". Measured against the reference that keeps LibreOffice's own blank pages: the largest fixture 875 → 774 reports, another 25 → 30 with its page count 27 → 29 against LibreOffice's 32.
This commit is contained in:
1 parent
2dacb1f86b
commit
7203d716af
15 files changed
+150
-13
No files matched your search
@@ -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 %)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
<span class="menu-sub">{t().ribbon.sectionBreakHint}</span>
|
||||
</button>
|
||||
{#if currentSection > 0}
|
||||
<hr />
|
||||
<div class="rb-menu-label">{t().ribbon.thisSection}</div>
|
||||
{#each [['odd', t().ribbon.startsOnOdd], ['even', t().ribbon.startsOnEven]] as [side, label]}
|
||||
<label class="check-row" title={t().ribbon.startsOnHint}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sectionStartsOn === side}
|
||||
onchange={(e) => setSectionProp({ startsOn: e.currentTarget.checked ? side as PageSide : null })}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } }
|
||||
: {}),
|
||||
|
||||
@@ -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(/<style:page-layout-properties\b[^>]*>/, (props) => {
|
||||
// The margins are a shift, so whatever the header/footer pass folded into them
|
||||
// survives; the paper is set outright.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 `<draw:frame>` whose `<draw:object>` resolves to a sub-document with a `math:math` root (or holds one inline) → a `formula` node. Our own `<annotation encoding="application/x-tex">` 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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') ?? '' : '';
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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('<w:type w:val="oddPage"');
|
||||
expect(sides(importDocx(bytes))).toEqual([null, 'odd']);
|
||||
});
|
||||
|
||||
it('leaves a section that takes any side alone', async () => {
|
||||
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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user