feat: page numbering in five formats, from a start value
Layout > Page numbers: 1 / i / I / a / A and a first-page number, both round-tripping. ODF has no document-level start — probed, LibreOffice puts style:page-number on the first paragraph's own style — so the export mints one there and the page layout carries style:num-format; Word takes both in the first section's w:pgNumType. The page *count* field stays decimal, as the one both of them write does.
This commit is contained in:
1 parent
40efc73f86
commit
a7092cfd1d
17 files changed
+272
-16
No files matched your search
+14
-3
@@ -37,6 +37,7 @@
|
||||
import { loadDocName, saveDocName, stripOdtExtension, sanitizeNameForFile } from './lib/storage/documentName';
|
||||
import { loadDocProperties, saveDocProperties, EMPTY_DOC_PROPERTIES, type DocProperties } from './lib/storage/docProperties';
|
||||
import { loadHyphenation, saveHyphenation } from './lib/storage/hyphenation';
|
||||
import { loadPageNumbering, savePageNumbering, DEFAULT_PAGE_NUMBERING, type PageNumbering } from './lib/storage/pageNumbering';
|
||||
import { loadDocumentLanguage, saveDocumentLanguage, odfFromLanguage, type DocumentLanguage } from './lib/storage/documentLanguage';
|
||||
import { spellController } from './lib/spell/controller';
|
||||
import LanguagePicker from './lib/components/LanguagePicker.svelte';
|
||||
@@ -154,6 +155,7 @@
|
||||
let docProps: DocProperties = $state(loadDocProperties());
|
||||
let docPropsOpen = $state(false);
|
||||
let hyphenate = $state(loadHyphenation());
|
||||
let pageNumbering: PageNumbering = $state(loadPageNumbering());
|
||||
let autoCorrectOpen = $state(false);
|
||||
|
||||
// Width of the hidden mirror span (below), so the title input grows/shrinks
|
||||
@@ -221,6 +223,10 @@
|
||||
saveHyphenation(hyphenate);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
savePageNumbering(pageNumbering);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
saveDocumentLanguage(documentLanguage);
|
||||
void spellController.setLanguage(documentLanguage);
|
||||
@@ -496,6 +502,7 @@
|
||||
extraHfSections = [];
|
||||
documentName = '';
|
||||
hyphenate = false;
|
||||
pageNumbering = { ...DEFAULT_PAGE_NUMBERING };
|
||||
docProps = { ...EMPTY_DOC_PROPERTIES };
|
||||
saveDocProperties(docProps);
|
||||
fileHandle = null;
|
||||
@@ -557,6 +564,7 @@
|
||||
spacingModel = result.spacingModel;
|
||||
pageRtl = result.rtl;
|
||||
hyphenate = result.hyphenate;
|
||||
pageNumbering = result.pageNumbering;
|
||||
// Adopt the document's spell-check language (the $effect switches the
|
||||
// controller + loads its dictionary). null = file declared none; keep ours.
|
||||
if (result.language) documentLanguage = result.language;
|
||||
@@ -638,7 +646,7 @@
|
||||
exportMenuOpen = false;
|
||||
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
|
||||
try {
|
||||
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate);
|
||||
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering);
|
||||
fileHandle = await saveOdt(bytes, suggestedFilename(json), fileHandle);
|
||||
} catch (err) {
|
||||
if ((err as DOMException)?.name === 'AbortError') return;
|
||||
@@ -654,7 +662,7 @@
|
||||
exportMenuOpen = false;
|
||||
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
|
||||
try {
|
||||
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate);
|
||||
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering);
|
||||
fileHandle = await saveAsOdt(bytes, suggestedFilename(json));
|
||||
} catch (err) {
|
||||
if ((err as DOMException)?.name === 'AbortError') return;
|
||||
@@ -672,7 +680,7 @@
|
||||
try {
|
||||
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
|
||||
const { buildDocx } = await import('./lib/export/docx');
|
||||
const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate);
|
||||
const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering);
|
||||
await saveAsDocx(bytes, suggestedFilenameDocx(json));
|
||||
} catch (err) {
|
||||
if ((err as DOMException)?.name === 'AbortError') return;
|
||||
@@ -887,6 +895,7 @@
|
||||
bind:pageOrientation
|
||||
bind:pageFormat
|
||||
bind:hyphenate
|
||||
bind:pageNumbering
|
||||
{hfActive}
|
||||
onManageStyles={openStyleManager}
|
||||
onManageTableStyles={() => openStyleManager('table')}
|
||||
@@ -1129,6 +1138,7 @@
|
||||
bind:pageOrientation
|
||||
bind:pageFormat
|
||||
bind:hyphenate
|
||||
bind:pageNumbering
|
||||
bind:hfDistances
|
||||
bind:differentFirstPage
|
||||
bind:differentOddEven
|
||||
@@ -1178,6 +1188,7 @@
|
||||
{spacingModel}
|
||||
{hyphenate}
|
||||
{documentLanguage}
|
||||
{pageNumbering}
|
||||
bind:extraHfSections
|
||||
{zoom}
|
||||
onZoom={setZoom}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
import { type Orientation } from '../storage/pageOrientation';
|
||||
import { type SpacingModel } from '../storage/spacingModel';
|
||||
import { NO_LANGUAGE } from '../storage/documentLanguage';
|
||||
import { DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../storage/pageNumbering';
|
||||
import { applyNoteVars } from '../storage/noteSettings';
|
||||
import { noteSettings } from '../storage/notes.svelte';
|
||||
import { RESYNC_NOTES } from '../editor/extensions/notes';
|
||||
@@ -43,7 +44,7 @@
|
||||
let {
|
||||
editor = $bindable(), tick = $bindable(0), currentPage = $bindable(1), numPages = $bindable(1),
|
||||
zoom = 100, onZoom, showFormattingMarks = false, showRuler = true, pageMargins = DEFAULT_MARGINS, orientation = 'portrait',
|
||||
pageFormat = 'A4', tabIntervalCm = DEFAULT_TAB_INTERVAL_CM, spacingModel = 'add', documentEpoch = 0, pageRtl = false, hyphenate = false, documentLanguage = 'en',
|
||||
pageFormat = 'A4', tabIntervalCm = DEFAULT_TAB_INTERVAL_CM, spacingModel = 'add', documentEpoch = 0, pageRtl = false, hyphenate = false, documentLanguage = 'en', pageNumbering = DEFAULT_PAGE_NUMBERING,
|
||||
headerDoc = $bindable(null), footerDoc = $bindable(null), hfDistances = DEFAULT_HF_DISTANCES,
|
||||
headerFirstDoc = $bindable(null), footerFirstDoc = $bindable(null), differentFirstPage = false,
|
||||
headerEvenDoc = $bindable(null), footerEvenDoc = $bindable(null), differentOddEven = false,
|
||||
@@ -59,6 +60,8 @@
|
||||
pageRtl?: boolean;
|
||||
/** Automatic hyphenation; the browser needs the document language to pick its patterns. */
|
||||
hyphenate?: boolean; documentLanguage?: string;
|
||||
/** How the page-number field counts (format + start value). */
|
||||
pageNumbering?: PageNumbering;
|
||||
headerDoc?: HfDoc; footerDoc?: HfDoc; hfDistances?: HfDistances;
|
||||
headerFirstDoc?: HfDoc; footerFirstDoc?: HfDoc; differentFirstPage?: boolean;
|
||||
headerEvenDoc?: HfDoc; footerEvenDoc?: HfDoc; differentOddEven?: boolean;
|
||||
@@ -1022,6 +1025,7 @@
|
||||
bind:extraHfSections
|
||||
{sectionStartPages}
|
||||
{chapterStarts}
|
||||
{pageNumbering}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import { cmToPx, PX_PER_CM, type PageMargins } from '../storage/pageMargins';
|
||||
import { type Orientation } from '../storage/pageOrientation';
|
||||
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
|
||||
import { DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../storage/pageNumbering';
|
||||
import { formatOrdinal } from '../utils/orderedListTypes';
|
||||
import { t } from '../i18n/i18n.svelte';
|
||||
|
||||
let {
|
||||
@@ -29,6 +31,7 @@
|
||||
extraHfSections = $bindable([]),
|
||||
sectionStartPages = [],
|
||||
chapterStarts = [],
|
||||
pageNumbering = DEFAULT_PAGE_NUMBERING,
|
||||
}: {
|
||||
headerDoc: HfDoc;
|
||||
footerDoc: HfDoc;
|
||||
@@ -40,6 +43,8 @@
|
||||
differentOddEven?: boolean;
|
||||
numPages: number;
|
||||
currentPage: number;
|
||||
/** How the page-number field counts (format + start value). */
|
||||
pageNumbering?: PageNumbering;
|
||||
pageMargins: PageMargins;
|
||||
orientation: Orientation;
|
||||
pageFormat?: PageFormat;
|
||||
@@ -219,6 +224,12 @@
|
||||
return { update: apply };
|
||||
}
|
||||
|
||||
// The number a page shows: its position offset by the document's start value, in the
|
||||
// document's own format.
|
||||
function pageLabel(page: number): string {
|
||||
return formatOrdinal(page + pageNumbering.start - 1, pageNumbering.format);
|
||||
}
|
||||
|
||||
// Replace the placeholder text in every page-field span with the real value:
|
||||
// current page number, or the total page count. Re-runs when its param changes.
|
||||
function patchFields(node: HTMLElement, params: ZoneParams) {
|
||||
@@ -226,7 +237,8 @@
|
||||
for (const el of Array.from(node.querySelectorAll('[data-page-field]'))) {
|
||||
const kind = el.getAttribute('data-page-field');
|
||||
if (kind === 'chapter') el.textContent = chapterOn(page, Number(el.getAttribute('data-level')) || 1);
|
||||
else el.textContent = String(kind === 'count' ? total : page);
|
||||
// The count stays decimal, as the field LibreOffice and Word write does.
|
||||
else el.textContent = kind === 'count' ? String(total) : pageLabel(page);
|
||||
}
|
||||
};
|
||||
apply(params);
|
||||
@@ -334,7 +346,7 @@
|
||||
for (const el of Array.from(liveMount.querySelectorAll('[data-page-field]'))) {
|
||||
const kind = el.getAttribute('data-page-field');
|
||||
if (kind === 'chapter') el.textContent = chapterOn(editingPage, Number(el.getAttribute('data-level')) || 1);
|
||||
else el.textContent = String(kind === 'count' ? numPages : editingPage);
|
||||
else el.textContent = kind === 'count' ? String(numPages) : pageLabel(editingPage);
|
||||
}
|
||||
// Content height (unscaled by the zoom transform) drives the active zone's frame.
|
||||
const tt = liveMount.querySelector('.tiptap') as HTMLElement | null;
|
||||
|
||||
@@ -27,11 +27,13 @@
|
||||
import { listContext } from '../editor/extensions/indent';
|
||||
import { stepFontSize } from '../editor/extensions/shortcuts';
|
||||
import { findColumns, DEFAULT_COLUMN_GAP_CM } from '../editor/extensions/columns';
|
||||
import { DEFAULT_PAGE_NUMBERING, PAGE_NUM_FORMATS, clampPageStart, type PageNumbering } from '../storage/pageNumbering';
|
||||
import { formatOrdinal } from '../utils/orderedListTypes';
|
||||
import { t } from '../i18n/i18n.svelte';
|
||||
import { shortcutHint, type ShortcutId } from '../editor/shortcuts';
|
||||
|
||||
let { editor, tick, showFormattingMarks = $bindable(), showRuler = $bindable(true), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable<Orientation>('portrait'), pageFormat = $bindable<PageFormat>('A4'), hyphenate = $bindable(false), hfDistances = $bindable(DEFAULT_HF_DISTANCES), differentFirstPage = $bindable(false), differentOddEven = $bindable(false), hfActive = null, onEditZone, onDebugDump, onManageTableStyles, onNoteOptions }:
|
||||
{ editor: Editor | null; tick: number; showFormattingMarks: boolean; showRuler?: boolean; pageMargins?: PageMargins; pageOrientation?: Orientation; pageFormat?: PageFormat; hyphenate?: boolean; hfDistances?: HfDistances; differentFirstPage?: boolean; differentOddEven?: boolean; hfActive?: 'header' | 'footer' | null; onEditZone?: (zone: 'header' | 'footer') => void; onDebugDump?: () => void; onManageTableStyles?: () => void; onNoteOptions?: () => void } = $props();
|
||||
let { editor, tick, showFormattingMarks = $bindable(), showRuler = $bindable(true), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable<Orientation>('portrait'), pageFormat = $bindable<PageFormat>('A4'), hyphenate = $bindable(false), pageNumbering = $bindable(DEFAULT_PAGE_NUMBERING), hfDistances = $bindable(DEFAULT_HF_DISTANCES), differentFirstPage = $bindable(false), differentOddEven = $bindable(false), hfActive = null, onEditZone, onDebugDump, onManageTableStyles, onNoteOptions }:
|
||||
{ editor: Editor | null; tick: number; showFormattingMarks: boolean; showRuler?: boolean; pageMargins?: PageMargins; pageOrientation?: Orientation; pageFormat?: PageFormat; hyphenate?: boolean; pageNumbering?: PageNumbering; hfDistances?: HfDistances; differentFirstPage?: boolean; differentOddEven?: boolean; hfActive?: 'header' | 'footer' | null; onEditZone?: (zone: 'header' | 'footer') => void; onDebugDump?: () => void; onManageTableStyles?: () => void; onNoteOptions?: () => void } = $props();
|
||||
|
||||
const PAGE_FORMATS = Object.keys(PAGE_FORMAT_CM) as PageFormat[];
|
||||
|
||||
@@ -1417,6 +1419,22 @@
|
||||
</button>
|
||||
{#if layoutOpen}
|
||||
<div class="layout-dropdown">
|
||||
<div class="lh-section-label">{t().ribbon.pageNumberFormat}</div>
|
||||
<div class="pagenum-row">
|
||||
<select class="format-select" bind:value={pageNumbering.format}>
|
||||
{#each PAGE_NUM_FORMATS as f}
|
||||
<option value={f}>{[1, 2, 3].map((n) => formatOrdinal(n, f)).join(', ')}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="9999"
|
||||
title={t().ribbon.pageNumberStart}
|
||||
value={pageNumbering.start}
|
||||
onchange={(e) => (pageNumbering = { ...pageNumbering, start: clampPageStart(Number(e.currentTarget.value)) })}
|
||||
/>
|
||||
</div>
|
||||
<div class="lh-section-label">{t().ribbon.hyphenation}</div>
|
||||
<label class="hyphen-row" title={t().ribbon.hyphenationHint}>
|
||||
<input type="checkbox" bind:checked={hyphenate} />
|
||||
@@ -2440,6 +2458,9 @@
|
||||
background: var(--color-btn-hover);
|
||||
}
|
||||
|
||||
.pagenum-row { display: flex; gap: 6px; align-items: center; }
|
||||
.pagenum-row input { width: 70px; }
|
||||
|
||||
.hyphen-row { display: flex; align-items: center; gap: 6px; padding: 2px 0 4px; cursor: pointer; }
|
||||
|
||||
.lh-section-label {
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
import type { HfZone } from '../../storage/headerFooter';
|
||||
import type { DocumentLanguage } from '../../storage/documentLanguage';
|
||||
import { DEFAULT_TAB_INTERVAL_CM } from '../../storage/tabInterval';
|
||||
import { DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../../storage/pageNumbering';
|
||||
|
||||
let {
|
||||
editor,
|
||||
@@ -45,6 +46,7 @@
|
||||
pageOrientation = $bindable<Orientation>('portrait'),
|
||||
pageFormat = $bindable<PageFormat>('A4'),
|
||||
hyphenate = $bindable(false),
|
||||
pageNumbering = $bindable(DEFAULT_PAGE_NUMBERING),
|
||||
hfActive = null,
|
||||
onManageStyles,
|
||||
onManageTableStyles,
|
||||
@@ -74,6 +76,7 @@
|
||||
pageOrientation?: Orientation;
|
||||
pageFormat?: PageFormat;
|
||||
hyphenate?: boolean;
|
||||
pageNumbering?: PageNumbering;
|
||||
hfActive?: HfZone | null;
|
||||
onManageStyles?: (family: StyleFamily) => void;
|
||||
onManageTableStyles?: (family: StyleFamily) => void;
|
||||
@@ -291,7 +294,7 @@
|
||||
{:else if tab === 'insert'}
|
||||
<InsertTab {editor} {tick} {hfActive} {pageMargins} {pageOrientation} {pageFormat} {onEditZone} {onManageTableStyles} />
|
||||
{:else if tab === 'layout'}
|
||||
<LayoutTab {editor} {tick} {hfActive} bind:pageMargins bind:pageOrientation bind:pageFormat bind:hyphenate onParagraphDialog={() => (paragraphDialogOpen = true)} />
|
||||
<LayoutTab {editor} {tick} {hfActive} bind:pageMargins bind:pageOrientation bind:pageFormat bind:hyphenate bind:pageNumbering onParagraphDialog={() => (paragraphDialogOpen = true)} />
|
||||
{:else if tab === 'references'}
|
||||
<ReferencesTab {editor} {tick} {hfActive} {onNoteOptions} />
|
||||
{:else if tab === 'review'}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
import { DEFAULT_MARGINS, type PageMargins } from '../../../storage/pageMargins';
|
||||
import type { Orientation } from '../../../storage/pageOrientation';
|
||||
import type { HfZone } from '../../../storage/headerFooter';
|
||||
import { DEFAULT_PAGE_NUMBERING, PAGE_NUM_FORMATS, clampPageStart, type PageNumbering } from '../../../storage/pageNumbering';
|
||||
import { formatOrdinal } from '../../../utils/orderedListTypes';
|
||||
import { t } from '../../../i18n/i18n.svelte';
|
||||
import { shortcutHint } from '../../../editor/shortcuts';
|
||||
|
||||
@@ -18,6 +20,7 @@
|
||||
pageOrientation = $bindable<Orientation>('portrait'),
|
||||
pageFormat = $bindable<PageFormat>('A4'),
|
||||
hyphenate = $bindable(false),
|
||||
pageNumbering = $bindable(DEFAULT_PAGE_NUMBERING),
|
||||
onParagraphDialog,
|
||||
}: {
|
||||
editor: Editor | null;
|
||||
@@ -27,6 +30,7 @@
|
||||
pageOrientation?: Orientation;
|
||||
pageFormat?: PageFormat;
|
||||
hyphenate?: boolean;
|
||||
pageNumbering?: PageNumbering;
|
||||
onParagraphDialog?: () => void;
|
||||
} = $props();
|
||||
|
||||
@@ -200,6 +204,37 @@
|
||||
active={hyphenate}
|
||||
onclick={() => (hyphenate = !hyphenate)}
|
||||
/>
|
||||
|
||||
<div class="rb-menu-wrap" use:clickOutside={'pageNum'}>
|
||||
<RibbonButton
|
||||
icon="pageNumber"
|
||||
label={t().ribbon.pageNumberFormat}
|
||||
title={t().ribbon.pageNumberFormat}
|
||||
caret
|
||||
active={isMenuOpen('pageNum')}
|
||||
onclick={() => toggleMenu('pageNum')}
|
||||
/>
|
||||
{#if isMenuOpen('pageNum')}
|
||||
<div class="ribbon-menu" use:anchored role="menu">
|
||||
<div class="rb-menu-label">{t().ribbon.pageNumberFormat}</div>
|
||||
{#each PAGE_NUM_FORMATS as f}
|
||||
<button class:selected={pageNumbering.format === f} onclick={() => (pageNumbering = { ...pageNumbering, format: f })}>
|
||||
{[1, 2, 3].map((n) => formatOrdinal(n, f)).join(', ')}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="rb-menu-label">{t().ribbon.pageNumberStart}</div>
|
||||
<label class="num-row">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="9999"
|
||||
value={pageNumbering.start}
|
||||
onchange={(e) => (pageNumbering = { ...pageNumbering, start: clampPageStart(Number(e.currentTarget.value)) })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</RibbonGroup>
|
||||
|
||||
<div class="ribbon-sep"></div>
|
||||
@@ -247,6 +282,9 @@
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
.num-row { display: flex; padding: 2px 12px 6px; }
|
||||
.num-row input { width: 84px; }
|
||||
|
||||
.rb-menu-wrap { position: relative; }
|
||||
|
||||
.col-preview { flex-shrink: 0; }
|
||||
|
||||
+14
-1
@@ -4,7 +4,7 @@ import {
|
||||
TableOfContents,
|
||||
Table, TableRow, TableCell, Header, Footer, PageNumber, SimpleField,
|
||||
AlignmentType, LevelFormat, UnderlineType, BorderStyle, ShadingType,
|
||||
WidthType, HeightRule, PageOrientation, LineRuleType, TableLayoutType, SectionType,
|
||||
WidthType, HeightRule, PageOrientation, LineRuleType, TableLayoutType, SectionType, NumberFormat,
|
||||
HorizontalPositionAlign, VerticalPositionRelativeFrom, HorizontalPositionRelativeFrom,
|
||||
TextWrappingType, TextWrappingSide, ImportedXmlComponent, TabStopType, LeaderType,
|
||||
} from 'docx';
|
||||
@@ -31,6 +31,13 @@ import { effectiveOrderedDefAt, formatOrdinal, childCycle, ROOT_ORDERED_CYCLE, t
|
||||
import { defaultBulletChar } from '../utils/bulletListTypes';
|
||||
import { normalizeColor, GENERATOR, MAX_HEADING_LEVEL, mergeJoinedParagraphsJson, twinFontName, type HfExport } from './odt';
|
||||
import { EMPTY_DOC_PROPERTIES, type DocProperties } from '../storage/docProperties';
|
||||
import { DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../storage/pageNumbering';
|
||||
|
||||
// The five page-number formats both word processors offer → Word's own names.
|
||||
const DOCX_PAGE_NUM_FORMAT = {
|
||||
'1': NumberFormat.DECIMAL, i: NumberFormat.LOWER_ROMAN, I: NumberFormat.UPPER_ROMAN,
|
||||
a: NumberFormat.LOWER_LETTER, A: NumberFormat.UPPER_LETTER,
|
||||
} as const;
|
||||
import { builtinStyleSheet, DEFAULT_STYLE, resolveStyle, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet';
|
||||
import { parseTableLook, regionText, type TableStyle } from '../styles/tableStyles';
|
||||
import { findFormat, renderFormat, docxPicture, localeTag, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT } from '../utils/dateTime';
|
||||
@@ -1309,6 +1316,7 @@ export async function buildDocx(
|
||||
notesSettings: NoteSettings = DEFAULT_NOTE_SETTINGS,
|
||||
props: DocProperties = EMPTY_DOC_PROPERTIES,
|
||||
hyphenate = false,
|
||||
pageNumbering: PageNumbering = DEFAULT_PAGE_NUMBERING,
|
||||
): Promise<Uint8Array> {
|
||||
docLangTag = localeTag(language ? language.language : 'en');
|
||||
exportSheet = styles;
|
||||
@@ -1373,6 +1381,11 @@ export async function buildDocx(
|
||||
left: cmToTwip(m.left), right: cmToTwip(m.right),
|
||||
header: cmToTwip(Math.min(headerDist, m.top)), footer: cmToTwip(Math.min(footerDist, m.bottom)),
|
||||
},
|
||||
// w:pgNumType. Word restarts numbering at every section carrying it, so only the
|
||||
// first gets it — the rest continue.
|
||||
...(i === 0 && (pageNumbering.format !== '1' || pageNumbering.start !== 1)
|
||||
? { pageNumbers: { formatType: DOCX_PAGE_NUM_FORMAT[pageNumbering.format], ...(pageNumbering.start !== 1 ? { start: pageNumbering.start } : {}) } }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
// Fresh instances per section (Word's per-sectPr references, i.e. no "Link to
|
||||
|
||||
+44
-4
@@ -8,6 +8,7 @@ import { DEFAULT_TAB_INTERVAL_CM } from '../storage/tabInterval';
|
||||
import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc, type HfSet } from '../storage/headerFooter';
|
||||
import { DEFAULT_NOTE_SETTINGS, NOTE_FONT_SIZE_PT, NOTE_INDENT_CM, type NoteKind, type NoteSettings } from '../storage/noteSettings';
|
||||
import { EMPTY_DOC_PROPERTIES, keywordList, type DocProperties } from '../storage/docProperties';
|
||||
import { DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../storage/pageNumbering';
|
||||
import { builtinStyleSheet, DEFAULT_STYLE, resolveStyle, type StyleSheet, type TextProps } from '../styles/styleSheet';
|
||||
import {
|
||||
TABLE_REGIONS, parseTableLook, regionText, type TableLook, type TableRegion,
|
||||
@@ -2212,7 +2213,7 @@ function rezipOdt(files: Record<string, Uint8Array>): Uint8Array {
|
||||
// Rewrite styles.xml to match the editor's preview: default font Liberation Serif →
|
||||
// Times New Roman (metric-identical; see EXPORT_FONT), and Heading_20_1/2/3 sizes &
|
||||
// margins → the editor's values (odf-kit's defaults are larger; HEADING_STYLE_OVERRIDES).
|
||||
function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null, pageFormat: PageFormat, orientation: Orientation, sheet: StyleSheet, used: Set<string>, usedTables: Set<string> = new Set(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, mirrored = false, rtl = false, notes: NoteSettings = DEFAULT_NOTE_SETTINGS, hyphenate = false): Uint8Array {
|
||||
function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null, pageFormat: PageFormat, orientation: Orientation, sheet: StyleSheet, used: Set<string>, usedTables: Set<string> = new Set(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, mirrored = false, rtl = false, notes: NoteSettings = DEFAULT_NOTE_SETTINGS, hyphenate = false, pageNumbering: PageNumbering = DEFAULT_PAGE_NUMBERING): Uint8Array {
|
||||
const files = unzipSync(odtBytes);
|
||||
const stylesBytes = files['styles.xml'];
|
||||
if (!stylesBytes) return odtBytes;
|
||||
@@ -2238,6 +2239,15 @@ function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; countr
|
||||
styles = styles.replace(/<style:page-layout-properties /, '<style:page-layout-properties style:writing-mode="rl-tb" ');
|
||||
}
|
||||
|
||||
// How the page-number field counts. ODF keeps the format on the page layout; the start
|
||||
// value is a property of the document's first paragraph (applyPageNumberStart).
|
||||
if (pageNumbering.format !== '1') {
|
||||
styles = styles.replace(
|
||||
/<style:page-layout-properties /,
|
||||
`<style:page-layout-properties style:num-format="${pageNumbering.format}" `,
|
||||
);
|
||||
}
|
||||
|
||||
// Document spell-check language: set fo:language/fo:country on the base
|
||||
// Standard paragraph style, which every paragraph inherits from. LibreOffice
|
||||
// and Word read this as the document default language.
|
||||
@@ -3558,7 +3568,7 @@ export type HfExport = {
|
||||
};
|
||||
|
||||
// The full document → .odt pipeline, DOM-free; returns the .odt bytes.
|
||||
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null, pageFormat: PageFormat = 'A4', styles: StyleSheet = builtinStyleSheet(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, spacingModel: SpacingModel = 'add', rtl = false, notesSettings: NoteSettings = DEFAULT_NOTE_SETTINGS, props: DocProperties = EMPTY_DOC_PROPERTIES, hyphenate = false): Promise<Uint8Array> {
|
||||
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null, pageFormat: PageFormat = 'A4', styles: StyleSheet = builtinStyleSheet(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, spacingModel: SpacingModel = 'add', rtl = false, notesSettings: NoteSettings = DEFAULT_NOTE_SETTINGS, props: DocProperties = EMPTY_DOC_PROPERTIES, hyphenate = false, pageNumbering: PageNumbering = DEFAULT_PAGE_NUMBERING): Promise<Uint8Array> {
|
||||
// Images become IMG sentinels before serialization; applyImages resolves them and writes
|
||||
// the Pictures/ + manifest entries. Text boxes and columns hoist after replacePageBreaks
|
||||
// (so PGB misses their blocks) and before the inline passes (which then cover them).
|
||||
@@ -3786,12 +3796,42 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
// Effects first: applyCharacterStyles then clones the style that already carries them.
|
||||
const withNamedStyles = applyCharacterStyles(applyTextEffects(applyParagraphStyles(withParaBoxes)));
|
||||
const usedTables = new Set(tableStyleNames.filter((t): t is TableStyleRef => !!t).map(t => t.name));
|
||||
const withStyles = rewriteStylesXml(withNamedStyles, language ?? null, pageFormat, orientation, styles, usedStyleNames(docJson, styles), usedTables, tabIntervalCm, margins.mirrored === true, rtl, notesSettings, hyphenate);
|
||||
const withStyles = rewriteStylesXml(withNamedStyles, language ?? null, pageFormat, orientation, styles, usedStyleNames(docJson, styles), usedTables, tabIntervalCm, margins.mirrored === true, rtl, notesSettings, hyphenate, pageNumbering);
|
||||
const withHf = applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist, firstHeaderPara, firstFooterPara, hf?.pageCount ?? 1, hfImages, evenHeaderPara, evenFooterPara);
|
||||
// Sections past the first get their own master page, which is where ODF keeps a
|
||||
// section's header/footer; the SEC-marked block points at it.
|
||||
const withSections = applySectionMasterPages(withHf, hf?.sections ?? [], hf?.pageCount ?? 1, margins);
|
||||
return applyDocProperties(applySpacingModel(withSections, spacingModel), props);
|
||||
return applyDocProperties(applyPageNumberStart(applySpacingModel(withSections, spacingModel), pageNumbering.start), props);
|
||||
}
|
||||
|
||||
// The document's first page number. ODF has no document-level start: LibreOffice puts
|
||||
// `style:page-number` on the first paragraph's own style (probed), beside the master page
|
||||
// it names.
|
||||
function applyPageNumberStart(odtBytes: Uint8Array, start: number): Uint8Array {
|
||||
if (start <= 1) return odtBytes;
|
||||
const files = unzipSync(odtBytes);
|
||||
const contentBytes = files['content.xml'];
|
||||
if (!contentBytes) return odtBytes;
|
||||
const content = strFromU8(contentBytes);
|
||||
const body = /<office:text\b[^>]*>/.exec(content);
|
||||
if (!body) return odtBytes;
|
||||
const first = /<text:(p|h)\b([^>]*)>/.exec(content.slice(body.index + body[0].length));
|
||||
if (!first) return odtBytes;
|
||||
const at = body.index + body[0].length + first.index;
|
||||
const srcM = /text:style-name="([^"]*)"/.exec(first[2]);
|
||||
const source = srcM ? srcM[1] : 'Standard';
|
||||
const name = 'PgNumStart';
|
||||
const def = findAutoStyle(content, source);
|
||||
const minted = def
|
||||
? cloneStyleWithParaProps(def, name, `style:page-number="${start}"`)
|
||||
: `<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="${source}"><style:paragraph-properties style:page-number="${start}"/></style:style>`;
|
||||
const tag = srcM
|
||||
? first[0].replace(/text:style-name="[^"]*"/, `text:style-name="${name}"`)
|
||||
: `<text:${first[1]} text:style-name="${name}"${first[2]}>`;
|
||||
files['content.xml'] = strToU8(
|
||||
injectAutomaticStyles(content.slice(0, at) + tag + content.slice(at + first[0].length), minted),
|
||||
);
|
||||
return rezipOdt(files);
|
||||
}
|
||||
|
||||
// The descriptive metadata → meta.xml, which odf-kit already writes (generator +
|
||||
|
||||
@@ -623,6 +623,8 @@ const de: Messages = {
|
||||
noteOptions: 'Optionen',
|
||||
autoCorrect: 'AutoKorrektur',
|
||||
hyphenation: 'Silbentrennung',
|
||||
pageNumberFormat: 'Seitenzahlen',
|
||||
pageNumberStart: 'Beginnen bei',
|
||||
hyphenationHint: 'Automatisch trennen',
|
||||
debugDump: 'Dump',
|
||||
cellMargins: 'Zellenränder',
|
||||
|
||||
@@ -627,6 +627,8 @@ const en = {
|
||||
noteOptions: 'Options',
|
||||
autoCorrect: 'AutoCorrect',
|
||||
hyphenation: 'Hyphenation',
|
||||
pageNumberFormat: 'Page numbers',
|
||||
pageNumberStart: 'Start at',
|
||||
hyphenationHint: 'Hyphenate automatically',
|
||||
debugDump: 'Dump',
|
||||
cellMargins: 'Cell margins',
|
||||
|
||||
@@ -20,6 +20,7 @@ import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/
|
||||
import { EMPTY_HF_SET, type HfDoc, type HfSet } from '../storage/headerFooter';
|
||||
import { DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteNumFormat, type NoteSettings } from '../storage/noteSettings';
|
||||
import { EMPTY_DOC_PROPERTIES, type DocProperties } from '../storage/docProperties';
|
||||
import { clampPageStart, DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../storage/pageNumbering';
|
||||
import { applyUniformRunFont, pairAlignedFrames, sinkOffsetFrames, type OdtImportResult } from './odt';
|
||||
import { chartDataUrl } from './chart';
|
||||
import { deobfuscateOdttf, type EmbeddedFont } from '../fonts/embeddedFonts';
|
||||
@@ -228,6 +229,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
|
||||
// A right-to-left section (w:bidi): the columns fill from the right.
|
||||
rtl: !!finalSectPr && (() => { const b = fc(finalSectPr, 'bidi'); return !!b && onOff(b); })(),
|
||||
hyphenate: docAutoHyphenation(files),
|
||||
pageNumbering: docxPageNumbering(sectPr),
|
||||
orientation: sect.orientation,
|
||||
format: sect.format,
|
||||
tabIntervalCm: docTabInterval(files),
|
||||
@@ -2217,6 +2219,21 @@ function docTabInterval(files: Record<string, Uint8Array>): number {
|
||||
}
|
||||
}
|
||||
|
||||
// w:pgNumType on the first section: how the page-number field counts.
|
||||
const DOCX_PAGE_NUM_FORMAT: Record<string, NoteNumFormat> = {
|
||||
decimal: '1', lowerRoman: 'i', upperRoman: 'I', lowerLetter: 'a', upperLetter: 'A',
|
||||
};
|
||||
|
||||
function docxPageNumbering(sectPr: Element | null): PageNumbering {
|
||||
const el = fc(sectPr, 'pgNumType');
|
||||
if (!el) return { ...DEFAULT_PAGE_NUMBERING };
|
||||
const start = intAttr(el, W, 'start');
|
||||
return {
|
||||
format: DOCX_PAGE_NUM_FORMAT[el.getAttributeNS(W, 'fmt') ?? ''] ?? '1',
|
||||
start: start != null ? clampPageStart(start) : 1,
|
||||
};
|
||||
}
|
||||
|
||||
// Word's Layout ▸ Hyphenation, from settings.xml (absent = off, as in Word).
|
||||
function docAutoHyphenation(files: Record<string, Uint8Array>): boolean {
|
||||
const bytes = files['word/settings.xml'];
|
||||
|
||||
@@ -22,6 +22,7 @@ import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/
|
||||
import type { HfDoc, HfSet } from '../storage/headerFooter';
|
||||
import type { NoteKind, NoteSettings } from '../storage/noteSettings';
|
||||
import { EMPTY_DOC_PROPERTIES, type DocProperties } from '../storage/docProperties';
|
||||
import { clampPageStart, type PageNumbering } from '../storage/pageNumbering';
|
||||
import type { EmbeddedFont } from '../fonts/embeddedFonts';
|
||||
import { cellPaddingAttr, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding';
|
||||
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
|
||||
@@ -52,6 +53,8 @@ export interface OdtImportResult {
|
||||
rtl: boolean;
|
||||
// Automatic hyphenation (ODF fo:hyphenate on the base style, Word w:autoHyphenation).
|
||||
hyphenate: boolean;
|
||||
// How the page-number field counts (ODF style:num-format + style:page-number, Word w:pgNumType).
|
||||
pageNumbering: PageNumbering;
|
||||
// The grid every tab past the last custom stop falls on; the format's own fallback
|
||||
// when the file declares none.
|
||||
tabIntervalCm: number;
|
||||
@@ -676,6 +679,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
|
||||
format: geometry?.format ?? null,
|
||||
rtl: geometry?.rtl ?? false,
|
||||
hyphenate: resolver.documentHyphenation(),
|
||||
pageNumbering: { format: resolver.pageNumberFormat(), start: odfPageNumberStart(resolver, body) },
|
||||
tabIntervalCm: resolver.defaultTabInterval(),
|
||||
spacingModel: odfSpacingModel(files),
|
||||
header,
|
||||
@@ -821,6 +825,18 @@ function mergeHfBox(maps: Record<string, string>[]): Record<string, string> {
|
||||
return out;
|
||||
}
|
||||
|
||||
// The document's first page number: ODF has no document-level start, so LibreOffice puts
|
||||
// `style:page-number` on the first paragraph's own style.
|
||||
function odfPageNumberStart(resolver: StyleResolver, body: Element): number {
|
||||
for (const el of Array.from(body.children)) {
|
||||
if (el.namespaceURI !== NS.text || (el.localName !== 'p' && el.localName !== 'h')) continue;
|
||||
const style = el.getAttributeNS(NS.text, 'style-name');
|
||||
const n = style ? Number(resolver.paraProps(style)['style:page-number']) : NaN;
|
||||
return Number.isFinite(n) ? clampPageStart(n) : 1;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// meta.xml → the document's descriptive properties (LibreOffice's File ▸ Properties).
|
||||
function odfDocProperties(files: Record<string, Uint8Array>): DocProperties {
|
||||
const bytes = files['meta.xml'];
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Orientation } from '../storage/pageOrientation';
|
||||
import { formatFromCm, type PageFormat } from '../storage/pageFormat';
|
||||
import { ODF_IMPLIED_TAB_CM } from '../storage/tabInterval';
|
||||
import { normalizeLeader, type TabAlign, type TabStop } from '../editor/extensions/tabStops';
|
||||
import { DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteSettings } from '../storage/noteSettings';
|
||||
import { DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteNumFormat, type NoteSettings } from '../storage/noteSettings';
|
||||
|
||||
// Resolves ODF style indirection for the importer: producers (our export, LibreOffice,
|
||||
// Word) spread formatting across named/automatic styles and parent-style-name chains.
|
||||
@@ -345,6 +345,14 @@ export class StyleResolver {
|
||||
return this.merged('paragraph', 'Standard').text['fo:hyphenate'] === 'true';
|
||||
}
|
||||
|
||||
// The page-number format, from the page layout of the governing master page.
|
||||
// ODF's own default is decimal, which is `1` here.
|
||||
pageNumberFormat(): NoteNumFormat {
|
||||
const props = this.pageLayoutEl()?.getElementsByTagNameNS(NS.style, 'page-layout-properties')[0] ?? null;
|
||||
const f = props?.getAttributeNS(NS.style, 'num-format') ?? '';
|
||||
return (['1', 'i', 'I', 'a', 'A'] as const).includes(f as NoteNumFormat) ? (f as NoteNumFormat) : '1';
|
||||
}
|
||||
|
||||
// The grid every tab past the last custom stop falls on, from the paragraph
|
||||
// default-style (Standard may override it). A file that declares none gets ODF's own
|
||||
// fallback, which is what LibreOffice renders it at.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
`editor.css` turns `--space-before` into padding or margin accordingly (CSS margins can
|
||||
only collapse, so `'add'` cannot be a margin).
|
||||
- **`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`.
|
||||
- **`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.
|
||||
- **`pageOrientation.ts`** — `'portrait' | 'landscape'`. `applyOrientationVars` sets `--user-page-{width,height}`; landscape swaps the A4 dimensions (matching odf-kit's automatic swap).
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { NoteNumFormat } from './noteSettings';
|
||||
|
||||
// How the page-number field counts: Word's Insert ▸ Page Number ▸ Format Page Numbers,
|
||||
// LibreOffice's page style ▸ Layout Settings. ODF keeps the format on the page layout
|
||||
// (`style:num-format`) and the start on the first paragraph (`style:page-number`);
|
||||
// Word keeps both in the section's `w:pgNumType`.
|
||||
|
||||
export type PageNumbering = { format: NoteNumFormat; start: number };
|
||||
|
||||
const KEY = 'edentext-page-numbering';
|
||||
|
||||
export const DEFAULT_PAGE_NUMBERING: PageNumbering = { format: '1', start: 1 };
|
||||
|
||||
// The five both word processors offer, in their own order.
|
||||
export const PAGE_NUM_FORMATS: NoteNumFormat[] = ['1', 'i', 'I', 'a', 'A'];
|
||||
|
||||
export function clampPageStart(n: number): number {
|
||||
return Number.isFinite(n) ? Math.min(9999, Math.max(1, Math.round(n))) : 1;
|
||||
}
|
||||
|
||||
export function loadPageNumbering(): PageNumbering {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (!raw) return { ...DEFAULT_PAGE_NUMBERING };
|
||||
const data = JSON.parse(raw) as Partial<PageNumbering>;
|
||||
return {
|
||||
format: PAGE_NUM_FORMATS.includes(data.format as NoteNumFormat) ? data.format as NoteNumFormat : '1',
|
||||
start: clampPageStart(Number(data.start)),
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_PAGE_NUMBERING };
|
||||
}
|
||||
}
|
||||
|
||||
export function savePageNumbering(value: PageNumbering): void {
|
||||
if (value.format === '1' && value.start === 1) localStorage.removeItem(KEY);
|
||||
else localStorage.setItem(KEY, JSON.stringify(value));
|
||||
}
|
||||
Reference in new issue
Block a user