fix: LibreOffice reads back what both exporters write

A section opening on a side hands its one-page master over to a follow-up
master instead of asking for a right-only page, which doubled the page count.
DOCX names its default style Normal and marks it as the default, zone tabs
stay tabs, footnote numbering rides in the section properties, and w:position
counts half-points. The importer keeps content controls, shape text boxes and
index page numbers, and opens no phantom section on the leading master.
Editing rejects the nesting the formats cannot hold, note repairs ride the
undo history, and the showcase files are rebuilt.
This commit is contained in:
Steffen Becker committed 2026-09-06 19:07:15 +02:00
1 parent 0d5c17a704
commit 3b6cb06e6f
31 files changed
+214 -59

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 175 KiB

+14 -1
View File
@@ -23,6 +23,7 @@ import { ListStyle } from './extensions/listStyle';
import { Table, TableHeader, TableCell } from '@tiptap/extension-table';
import { ResizableTableRow } from './extensions/tableRow';
import History from '@tiptap/extension-history';
import { inNote } from './extensions/notes';
import Placeholder from '@tiptap/extension-placeholder';
import { t } from '../i18n/i18n.svelte';
@@ -198,7 +199,19 @@ export const extensions = [
// resizable:false keeps TipTap's columnResizing plugin off; TableView, the two
// resize plugins, and ResizableTableRow supply the drag handling instead. Table
// (unlike TableKit) doesn't auto-add children, so ResizableTableRow is listed here.
Table.configure({ resizable: false, View: TableView }),
// No table inside a table (neither exporter writes a nested one), in a list item (ODF's
// holds paragraphs, headings and lists) or in a note (whose inline content would take
// it wrapped in a text box).
Table.extend({
addCommands() {
const parent = this.parent?.() ?? {};
return { ...parent, insertTable: (options) => (props) => {
if (inNote(props.state)) return false;
for (let d = props.state.selection.$from.depth; d > 0; d--) if (['table', 'listItem'].includes(props.state.selection.$from.node(d).type.name)) return false;
return parent.insertTable?.(options)(props) ?? false;
} };
},
}).configure({ resizable: false, View: TableView }),
ResizableTableRow,
TableHeader,
TableCell,
+6
View File
@@ -52,3 +52,9 @@ Five topics are large enough to have their own deep-dive — read the file befor
`extensions.ts` deliberately keeps TipTap's `resizable: false` (its columnResizing plugin stays
unloaded) and supplies the custom table view + drag plugins instead.
**What a command refuses, and why** (found by `tests/monkey/`): no note in a text box and no
text box in a note (neither format holds one there); no table in a table, a list item or a
note (nothing exports a nested one, ODF's list item holds no table, and ProseMirror would
wrap one in a text box to fit a note's inline content); a selection stops at the notes'
boundary (`clampToSide`); the notes plugin's repairs ride the history event they follow.
+35 -6
View File
@@ -194,8 +194,17 @@ function strayRefsInNotes(doc: PMNode): number[] {
}
export function inNote(state: EditorState): boolean {
return inside(state, 'note');
}
// Both products refuse a note in a text box, and neither file format holds one there.
export function inTextBox(state: EditorState): boolean {
return inside(state, 'textBox');
}
function inside(state: EditorState, type: string): boolean {
const $from = state.selection.$from;
for (let d = $from.depth; d > 0; d--) if ($from.node(d).type.name === 'note') return true;
for (let d = $from.depth; d > 0; d--) if ($from.node(d).type.name === type) return true;
return false;
}
@@ -275,7 +284,7 @@ export const Notes = Extension.create<NotesOptions>({
if (!refType || !noteType || !sectionType) return false;
// No note inside a note: LibreOffice and Word both refuse it, and the anchor
// would reference a note that can never be numbered.
if (inNote(state)) return false;
if (inNote(state) || inTextBox(state)) return false;
if (!dispatch) return true;
const id = newNoteId();
@@ -363,6 +372,10 @@ export const Notes = Extension.create<NotesOptions>({
// both at once would need positions from a document this transaction is still
// rewriting.
appendTransaction(trs, _oldState, newState) {
if (trs.some((tr) => tr.selectionSet)) {
const clamped = clampToSide(newState);
if (clamped) return clamped;
}
if (!trs.some((tr) => tr.docChanged || tr.getMeta(RESYNC_NOTES))) return null;
return syncStructure(newState) ?? syncNumbers(newState, getSettings());
},
@@ -371,8 +384,24 @@ export const Notes = Extension.create<NotesOptions>({
},
});
// A selection reaching across the notes' boundary stops at it: a replace over the
// boundary would carry body content into a note, or a note's out — neither product lets
// a selection span the two. The anchor's side wins.
function clampToSide(state: EditorState): Transaction | null {
const section = findNoteSection(state.doc);
if (!section) return null;
const { $anchor, $head } = state.selection;
const inNotes = (pos: number) => pos > section.pos && pos < section.pos + section.node.nodeSize;
const anchorIn = inNotes($anchor.pos);
if (anchorIn === inNotes($head.pos)) return null;
const limit = state.doc.resolve(anchorIn ? section.pos + 1 : section.pos);
return state.tr.setSelection(TextSelection.between($anchor, limit));
}
// Keep exactly one note per anchor, in anchor order. Returns null when the document
// already satisfies that, so the pass converges after one repair.
// already satisfies that, so the pass converges after one repair. The repair rides the
// history event of the change it follows (undo replays both; kept out, a later undo
// lands on positions the repair moved).
function syncStructure(state: EditorState): Transaction | null {
const noteType = state.schema.nodes.note;
const sectionType = state.schema.nodes.noteSection;
@@ -384,7 +413,7 @@ function syncStructure(state: EditorState): Transaction | null {
if (strays.length) {
const tr = state.tr;
for (let i = strays.length - 1; i >= 0; i--) tr.delete(strays[i], strays[i] + 1);
return tr.setMeta('addToHistory', false);
return tr;
}
const refs = collectNoteRefs(state.doc);
@@ -423,7 +452,7 @@ function syncStructure(state: EditorState): Transaction | null {
else if (!section) tr.insert(tr.doc.content.size, sectionType.create(null, wanted));
else tr.replaceWith(section.pos + 1, section.pos + section.node.nodeSize - 1, wanted);
}
return tr.setMeta('addToHistory', false);
return tr;
}
// Write the running label onto every anchor and its note. Cached attrs, so nothing is
@@ -457,5 +486,5 @@ function syncNumbers(state: EditorState, settings: NoteSettings): Transaction |
changed = true;
});
return changed ? tr.setMeta('addToHistory', false) : null;
return changed ? tr : null;
}
+1 -1
View File
@@ -390,7 +390,7 @@ class TocView {
const levelStyles = this.node()?.attrs?.levelStyles as (string | null)[] | null | undefined;
entries.forEach((e, i) => {
const row = document.createElement('div');
row.className = `toc-entry toc-level-${e.level}`;
row.className = `toc-entry toc-level-${e.level}${noPage ? ' toc-nopage' : ''}`;
const levelStyle = levelStyles?.[e.level - 1];
if (levelStyle) row.dataset.style = levelStyle;
const text = document.createElement('span');
+4 -1
View File
@@ -3,6 +3,7 @@ import type { CommandProps } from '@tiptap/core';
import TextAlign from '@tiptap/extension-text-align';
import type { Editor } from '@tiptap/core';
import { DOMSerializer, Fragment } from '@tiptap/pm/model';
import { inNote } from './notes';
import type { DOMOutputSpec, Node as PMNode, Schema, Slice } from '@tiptap/pm/model';
import { NodeSelection, Selection, TextSelection, Plugin } from '@tiptap/pm/state';
import type { EditorState } from '@tiptap/pm/state';
@@ -307,7 +308,9 @@ export const TextBox = Node.create({
const type = state.schema.nodes.textBox;
const paraType = state.schema.nodes.paragraph;
const para = paraType?.createAndFill();
if (!type || !paraType || !para) return false;
// Not in a note: both products refuse a frame there, and the ODT note body
// holds inline content only.
if (!type || !paraType || !para || inNote(state)) return false;
const { $from } = state.selection;
const nested = boxDepthAt($from) >= 0 && $from.depth >= 1;
const at = nested ? $from.after(1) : state.selection.from;
+12
View File
@@ -80,6 +80,18 @@ fallback download shows a one-time hint (`edentext-download-hint`) that the brow
shared layout put a footer-less chapter's body at the footer distance). A variant zone
travels with its running one, in the schema's order (running, left, first); the watermark
is injected after the section masters exist, so their pages show it too.
- **A section opening on a side hands over after its first page** (`masterPageXml` part
`right`/`left` → `rest`): a right-only master makes every page of it a right page in
LibreOffice, with a blank one between any two — measured: a book's 93 pages became 189.
- **A tab in a document-level zone is `<text:tab/>`** (`applyHfPostProcess`): odf-kit writes it
bare, and LibreOffice reads a bare tab as a space.
- **DOCX styles are named for Word's map**: the default style is `Normal` with `w:default="1"`
(LibreOffice keeps any other name beside its Standard as "… (WW)", and a reader without the
flag falls back to docDefaults); `w:position` is half-points (LibreOffice ignores a unit);
`w:footnotePr` rides every `w:sectPr` (LibreOffice reads none from settings.xml);
`lastModifiedBy` is the author (LibreOffice shows the modifier); a page field carries a
result; a later columns group of a section names no zones ("Link to Previous" — a
reference of its own makes LibreOffice switch page styles there, with a page break).
- **`style:name` is an NCName**: every other character travels as LibreOffice's `_hex_`
(`odfStyleName`, "&" → `_26_`), the display name carries the real one; a sentinel's
payload is serialized text, so its XML escapes come off first.
+38 -18
View File
@@ -498,7 +498,9 @@ function runPropsFromMarks(marks: TiptapNode['marks'] = [], force: TextProps = {
if (caps === 'smallCaps') props.smallCaps = true;
else if (caps === 'uppercase') props.allCaps = true;
const pos = ts?.attrs?.textPosition;
if (typeof pos === 'number' && pos) props.position = `${pos}pt`;
// Half-points, as Word writes them: LibreOffice takes the number and ignores a unit
// behind it. The library's type wants the unit, the file does not.
if (typeof pos === 'number' && pos) props.position = String(Math.round(pos * 2)) as `${number}pt`;
const ff = ts?.attrs?.fontFamily ?? force.fontFamily;
if (ff) props.font = String(ff) === SCREEN_FONT ? DOC_FONT : String(ff);
@@ -1796,22 +1798,22 @@ function orderDocxSettings(bytes: Uint8Array): Uint8Array {
return zipSync(out);
}
// Post-pack pass (runs last): the library writes a paragraph's w:pBdr sides as top,
// bottom, left, right; the schema's sequence is top, left, bottom, right, between, bar.
// Post-pack pass (runs last) over what the library writes: a paragraph's w:pBdr sides
// as top, bottom, left, right (the schema's sequence is top, left, bottom, right, between,
// bar), and a page field with no cached result (a reader shows what it finds there).
const PBDR_ORDER = ['w:top', 'w:left', 'w:bottom', 'w:right', 'w:between', 'w:bar'];
function orderParagraphBorders(bytes: Uint8Array): Uint8Array {
function patchPackedXml(bytes: Uint8Array): Uint8Array {
const files = unzipSync(bytes);
let changed = false;
for (const [path, data] of Object.entries(files)) {
if (!/^word\/.*\.xml$/.test(path)) continue;
const xml = strFromU8(data);
if (!xml.includes('<w:pBdr>')) continue;
const sorted = xml.replace(/<w:pBdr>([\s\S]*?)<\/w:pBdr>/g, (_m, inner: string) => {
const sides = inner.match(/<w:(?:top|left|bottom|right|between|bar)\b[^>]*\/>/g) ?? [];
if (sides.join('') !== inner) return _m;
const rank = (s: string) => PBDR_ORDER.indexOf(/^<(w:\w+)/.exec(s)![1]);
return `<w:pBdr>${[...sides].sort((a, b) => rank(a) - rank(b)).join('')}</w:pBdr>`;
});
}).replace(/(<w:instrText[^>]*>\s*(?:PAGE|NUMPAGES)\s*<\/w:instrText><w:fldChar w:fldCharType="separate"\/>)(<w:fldChar w:fldCharType="end"\/>)/g, '$1<w:t>1</w:t>$2');
if (sorted === xml) continue;
files[path] = strToU8(sorted);
changed = true;
@@ -1999,9 +2001,9 @@ const DOCX_NUM_FMT: Record<string, string> = {
'1': 'decimal', a: 'lowerLetter', A: 'upperLetter', i: 'lowerRoman', I: 'upperRoman',
};
// Post-pack pass: the document-wide <w:footnotePr>/<w:endnotePr>, which the docx
// package does not expose. Word keeps its defaults in settings.xml, beside
// w:mirrorMargins.
// Post-pack pass: <w:footnotePr>/<w:endnotePr>, which the docx package does not expose.
// Word keeps the document's in settings.xml, LibreOffice reads a section's alone, so
// every w:sectPr repeats them (ahead of w:type, in the element's fixed order).
function applyNotePrDocx(bytes: Uint8Array, notes: NoteSettings): Uint8Array {
const files = unzipSync(bytes);
const setBytes = files['word/settings.xml'];
@@ -2020,6 +2022,11 @@ function applyNotePrDocx(bytes: Uint8Array, notes: NoteSettings): Uint8Array {
files['word/settings.xml'] = strToU8(
strFromU8(setBytes).replace(/(<w:settings\b[^>]*>)/, `$1${block('footnote')}${block('endnote')}`),
);
const docBytes = files['word/document.xml'];
if (docBytes) {
files['word/document.xml'] = strToU8(strFromU8(docBytes).replace(
/<w:sectPr\b[^>]*>(?:<w:(?:header|footer)Reference\b[^>]*\/>)*/g, (m) => `${m}${block('footnote')}${block('endnote')}`));
}
const out: Record<string, [Uint8Array, { level: 6 }]> = {};
for (const [path, data] of Object.entries(files)) out[path] = [data, { level: 6 }];
return zipSync(out);
@@ -2645,7 +2652,7 @@ function paragraphStyleOf(style: Style): IParagraphStyleOptions {
if (border) paragraph.border = border;
return {
id: docxStyleId(style.name),
name: style.name,
name: wordStyleName(style.name),
basedOn: style.parent ? docxStyleId(style.parent) : undefined,
next: style.next ? docxStyleId(style.next) : undefined,
quickFormat: true,
@@ -2671,9 +2678,14 @@ function runPropsOf(t: TextProps): Writable<IRunStylePropertiesOptions> {
// One registry character style → a Word character style (w:type="character").
function characterStyleOf(style: Style): ICharacterStyleOptions {
return { id: docxStyleId(style.name), name: style.name, quickFormat: true, run: runPropsOf(style.text) };
return { id: docxStyleId(style.name), name: wordStyleName(style.name), quickFormat: true, run: runPropsOf(style.text) };
}
// LibreOffice maps a DOCX style by its name: "Normal" is its Standard, any other name a
// style of its own beside it ("Standard (WW)"). Word's other names buy nothing: "Strong"
// comes back as a paragraph style, "Quote" as a new one (probed).
const wordStyleName = (name: string): string => (name === DEFAULT_STYLE ? 'Normal' : name);
// Word needs a referenced table style to exist. ODF/our model hold the banding, and the
// look is baked into the cells, so a name-only definition is enough (no w:tblStylePr).
function tableStyleXml(name: string): string {
@@ -2706,10 +2718,13 @@ function numberingStyleXml(name: string): string {
// Table and numbering styles are spliced post-pack: handing them to the library as
// importedStyles makes its Styles merge drop the factory set — w:docDefaults included.
function applyRawStylesDocx(bytes: Uint8Array, snippets: string[]): Uint8Array {
if (!snippets.length) return bytes;
const files = unzipSync(bytes);
const stylesXml = strFromU8(files['word/styles.xml']);
files['word/styles.xml'] = strToU8(stylesXml.replace('</w:styles>', snippets.join('') + '</w:styles>'));
// The default style says so (w:default): a reader falls back to its docDefaults
// otherwise, and the style's own size and kerning never apply.
files['word/styles.xml'] = strToU8(stylesXml
.replace('<w:style w:type="paragraph" w:styleId="Normal">', '<w:style w:type="paragraph" w:default="1" w:styleId="Normal">')
.replace('</w:styles>', snippets.join('') + '</w:styles>'));
const out: Record<string, [Uint8Array, { level: 6 }]> = {};
for (const [path, data] of Object.entries(files)) out[path] = [data, { level: 6 }];
return zipSync(out);
@@ -2933,8 +2948,10 @@ export async function buildDocx(
const doc = new Document({
// Word's File ▸ Info; an empty field is left out so it does not overwrite Word's own.
// The creator is the one the library fills in ("Un-named"), so it is always given.
// Creator and last modifier are what the library fills in ("Un-named") otherwise,
// and LibreOffice shows the modifier as the author.
creator: props.author.trim(),
lastModifiedBy: props.author.trim(),
...(props.title.trim() ? { title: props.title.trim() } : {}),
...(props.subject.trim() ? { subject: props.subject.trim() } : {}),
...(props.keywords.trim() ? { keywords: props.keywords.trim() } : {}),
@@ -2994,8 +3011,11 @@ export async function buildDocx(
? { column: { count: g.columns.count, space: cmToTwip(g.columns.gapCm), equalWidth: true } }
: {}),
},
headers: mkHeaders(g.section),
footers: mkFooters(g.section),
// A later columns group of its section links to the zones ("Link to Previous"): a
// reference of its own makes LibreOffice switch page styles there, which breaks
// the page as a continuous break never may.
headers: groups.findIndex((x) => x.section === g.section) === i ? mkHeaders(g.section) : undefined,
footers: groups.findIndex((x) => x.section === g.section) === i ? mkFooters(g.section) : undefined,
children: g.children.length ? g.children : [new Paragraph({})],
})),
});
@@ -3018,8 +3038,8 @@ export async function buildDocx(
const dims = pageDimsCm(pageFormat, orientation);
const foldMarked = applyFoldMarksDocx(bidi, foldMarks, dims.w * 10);
const marked = spacingAtPageStart ? foldMarked : applySpacingAtStartDocx(foldMarked);
if (isEmptyPageDecor(decor)) return orderParagraphBorders(orderDocxSettings(marked));
if (isEmptyPageDecor(decor)) return patchPackedXml(orderDocxSettings(marked));
const pt = (cm: number) => (cm / 2.54) * 72;
return orderParagraphBorders(orderDocxSettings(applyPageDecorDocx(marked, decor,
return patchPackedXml(orderDocxSettings(applyPageDecorDocx(marked, decor,
pt(dims.w - margins.left - margins.right), pt(dims.h - margins.top - margins.bottom))));
}
+42 -22
View File
@@ -5281,9 +5281,10 @@ function applyHfPostProcess(odtBytes: Uint8Array, margins: PageMargins, headerPa
if (!stylesBytes) return odtBytes;
let styles = strFromU8(stylesBytes);
// Same fix as collapseRunWhitespace: odf-kit joins runs with "\n", which would
// collapse into spurious spaces. styles.xml only has text:p inside header/footer.
styles = styles.replace(/<text:p\b[^>]*>[\s\S]*?<\/text:p>/g, (block) => block.replace(/\n/g, ''));
// Same fix as collapseRunWhitespace: odf-kit joins runs with "\n", which would collapse
// into spurious spaces, and writes a tab bare, which LibreOffice reads as a space.
// styles.xml only has text:p inside header/footer.
styles = styles.replace(/<text:p\b[^>]*>[\s\S]*?<\/text:p>/g, (block) => block.replace(/\n/g, '').replace(/\t/g, '<text:tab/>'));
styles = styles.split(LBR).join('<text:line-break/>');
styles = styles.replace(new RegExp(`${PGC}(\\d*)${PGC}`, 'g'), '<text:page-count>$1</text:page-count>');
styles = styles.replace(new RegExp(`${CHP}(\\d)${CHP}([\\s\\S]*?)${CHP}`, 'g'),
@@ -5421,7 +5422,8 @@ function hfVariantZoneXml(kind: 'header' | 'footer', suffix: 'first' | 'left' |
// A section's own master page: the Standard one cloned under its own name, carrying that
// section's zones. ODF has no per-section header/footer other than this.
function masterPageXml(name: string, layoutName: string, set: HfSet, pageCount: number, mint: (styleXml: string) => void, pfx: string): string {
// `part`: the whole section, the one right/left page it opens on, or the pages after it.
function masterPageXml(name: string, layoutName: string, set: HfSet, pageCount: number, mint: (styleXml: string) => void, pfx: string, part: 'all' | 'right' | 'left' | 'rest' = 'all', next?: string): string {
// A variant and its running zone travel as a pair: ODF allows a first/left zone only
// under a running one, and an absent left zone repeats the running one. Where either
// is set both are written, the blank one as an empty paragraph, which blanks its side.
@@ -5431,18 +5433,28 @@ function masterPageXml(name: string, layoutName: string, set: HfSet, pageCount:
return hfVariantZoneXml(kind, suffix, para as TiptapNode, pageCount, mint, pfx);
};
const on = (flag: boolean, running: HfDoc, variant: HfDoc) => flag && (!hfIsEmpty(running) || !hfIsEmpty(variant));
const hFirst = on(set.differentFirstPage, set.header, set.headerFirst);
const hEven = on(set.differentOddEven, set.header, set.headerEven);
const fFirst = on(set.differentFirstPage, set.footer, set.footerFirst);
const fEven = on(set.differentOddEven, set.footer, set.footerEven);
// In the schema's order: the running zone, its left variant, then its first-page one.
const body = zone('header', null, set.header, hFirst || hEven)
+ (hEven ? zone('header', 'left', set.headerEven, true) : '')
+ (hFirst ? zone('header', 'first', set.headerFirst, true) : '')
+ zone('footer', null, set.footer, fFirst || fEven)
+ (fEven ? zone('footer', 'left', set.footerEven, true) : '')
+ (fFirst ? zone('footer', 'first', set.footerFirst, true) : '');
return `<style:master-page style:name="${name}" style:page-layout-name="${layoutName}">${body}</style:master-page>`;
let body: string;
if (part === 'right' || part === 'left') {
// The one page this master governs shows the first-page variant, else the running
// zone — the left one on a left page.
const one = (running: HfDoc, first: HfDoc, left: HfDoc): HfDoc =>
set.differentFirstPage ? first : part === 'left' && set.differentOddEven ? left : running;
body = zone('header', null, one(set.header, set.headerFirst, set.headerEven))
+ zone('footer', null, one(set.footer, set.footerFirst, set.footerEven));
} else {
const hFirst = part === 'all' && on(set.differentFirstPage, set.header, set.headerFirst);
const hEven = on(set.differentOddEven, set.header, set.headerEven);
const fFirst = part === 'all' && on(set.differentFirstPage, set.footer, set.footerFirst);
const fEven = on(set.differentOddEven, set.footer, set.footerEven);
// In the schema's order: the running zone, its left variant, then its first-page one.
body = zone('header', null, set.header, hFirst || hEven)
+ (hEven ? zone('header', 'left', set.headerEven, true) : '')
+ (hFirst ? zone('header', 'first', set.headerFirst, true) : '')
+ zone('footer', null, set.footer, fFirst || fEven)
+ (fEven ? zone('footer', 'left', set.footerEven, true) : '')
+ (fFirst ? zone('footer', 'first', set.footerFirst, true) : '');
}
return `<style:master-page style:name="${name}" style:page-layout-name="${layoutName}"${next ? ` style:next-style-name="${next}"` : ''}>${body}</style:master-page>`;
}
// What a page border and its padding take off every page margin (they sit inside it).
@@ -5502,15 +5514,12 @@ function applySectionMasterPages(odtBytes: Uint8Array, sets: HfSet[], pageCount:
const layoutXml = new RegExp(`<style:page-layout\\b[^>]*style:name="${layout}"[\\s\\S]*?</style:page-layout>`).exec(styles)?.[0] ?? null;
const layouts: string[] = [];
const round3 = (v: number) => Math.round(v * 1000) / 1000;
const layoutFor = (index: number, set: HfSet): string => {
const layoutFor = (index: number, set: HfSet, side: 'right' | 'left' | null): string => {
const m = set.margins;
const paper = set.format || set.orientation ? pageDimsCm(set.format ?? format, set.orientation ?? orientation) : null;
const numFormat = set.pageNumberFormat ?? null;
const has = { header: !!(set.header || set.headerFirst || set.headerEven), footer: !!(set.footer || set.footerFirst || set.footerEven) };
const sameZones = has.header === docZones.header && has.footer === docZones.footer && !set.distances;
// 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 && sameZones && !side) || !layoutXml) return layout;
const body = m ?? margins;
const band = (kind: 'header' | 'footer', edge: 'top' | 'bottom'): { margin: number; minH: number | null } => {
@@ -5525,7 +5534,7 @@ function applySectionMasterPages(odtBytes: Uint8Array, sets: HfSet[], pageCount:
const bandXml = (kind: 'header' | 'footer', minH: number | null) => (minH == null
? `<style:${kind}-style/>`
: `<style:${kind}-style><style:header-footer-properties fo:min-height="${round3(minH)}cm" ${kind === 'header' ? 'fo:margin-bottom' : 'fo:margin-top'}="0cm" style:dynamic-spacing="false"/></style:${kind}-style>`);
const name = `${layout}Sec${index + 1}`;
const name = `${layout}Sec${index + 1}${side ? side[0] : ''}`;
let xml = (side ? layoutXml.replace(/\s*style:page-usage="[^"]*"/, '') : layoutXml)
.replace(`style:name="${layout}"`, `style:name="${name}"${side ? ` style:page-usage="${side}"` : ''}`)
.replace(/<style:page-layout-properties\b[^>]*>/, (props) => {
@@ -5561,7 +5570,18 @@ function applySectionMasterPages(odtBytes: Uint8Array, sets: HfSet[], pageCount:
for (const index of [...used].sort((a, b) => a - b)) {
const set = sets[index];
if (!set) continue;
pages.push(masterPageXml(`Section${index + 1}`, layoutFor(index, set), set, pageCount, (x) => hfStyles.push(x), `MS${index}`));
const mint = (x: string) => hfStyles.push(x);
const name = `Section${index + 1}`;
// The side a section opens on (page one is a right page whatever it says): a master
// for that page alone, handing over to one for the rest — every page of a right-only
// master is a right page in LibreOffice, with a blank one between any two.
const side = index > 0 && set.startsOn ? (set.startsOn === 'odd' ? 'right' : 'left') : null;
if (side) {
pages.push(masterPageXml(name, layoutFor(index, set, side), set, pageCount, mint, `MS${index}`, side, `${name}c`));
pages.push(masterPageXml(`${name}c`, layoutFor(index, set, null), set, pageCount, mint, `MS${index}c`, 'rest'));
} else {
pages.push(masterPageXml(name, layoutFor(index, set, null), set, pageCount, mint, `MS${index}`));
}
}
if (layouts.length) styles = styles.replace('</office:automatic-styles>', `${layouts.join('')}</office:automatic-styles>`);
if (pages.length) styles = styles.replace('</office:master-styles>', `${pages.join('')}</office:master-styles>`);
+12
View File
@@ -130,6 +130,18 @@ Body text with no resolved font falls back to the *document's own theme minor fo
anything with an outline level): read as one, every chapter came back wrapped in an
`orderedList` that swallowed the `w:pageBreakBefore` in front of it — `blockAttrs`
reads the break for `kind === 'body'` only.
- **The first block naming the master the document opens on switches nothing**
(`Ctx.leadingMaster`): LibreOffice names it on the first paragraph of every converted file,
and a section there shifted every zone set by one. A hand-over master whose one page shows
its successor's own zones (the left ones on a left page) opens the section on that side,
not a different first page (`sameAsRest`).
- **LibreOffice's own forms**: a DOCX preset shape keeps its OOXML name (`ooxml-rect` →
`shapeFromPrst`); a drawn shape's outline is `draw:stroke` alone (its Frame ancestor's
hairline `fo:border` is a text frame's); `loext:content-control` is read through, ours
(tag `edentext-placeholder`) as a placeholder field; an alphabetical index opens with a
separator template that names no page number, so the first entry level decides.
- **DOCX defaults**: a file flagging no `w:default` style means `Normal`; Word's `Strong`
character style is the registry's Strong Emphasis.
- **A heading is found by `w:outlineLvl`, not only by its style's name.** A file may set
its chapters in a style of its own ("Appendix 1") whose `w:basedOn` chain reaches
Heading1; `styleOutlineLvl` walks that chain, and the exporter writes the level on the
+4 -1
View File
@@ -309,6 +309,8 @@ export class DocxStyles {
else this.charStyleNames.set(id, name);
}
}
// A file flagging no default style means the one Word names "Normal".
if (!this.defaultParaStyle && this.ownRun.has('Normal')) this.defaultParaStyle = 'Normal';
}
// Paragraph styles defined in the file: display name and parent, for the style registry.
@@ -325,8 +327,9 @@ export class DocxStyles {
}
// Character styles defined in the file (w:type="character"), for the style registry.
// Word's "Strong" is the registry's Strong Emphasis (the export writes it so too).
namedCharacterStyles(): Map<string, string> {
return new Map(this.charStyleNames);
return new Map([...this.charStyleNames].map(([id, name]) => [id, name === 'Strong' ? 'Strong Emphasis' : name]));
}
// A w:tblStyle id → the style's display name (the id itself when it declares none).
+22 -4
View File
@@ -152,6 +152,7 @@ type Ctx = {
pageRtl: boolean;
// Master pages the body switches to, in order — one section each past the first.
masterPages: string[];
leadingMaster: string; // the master the document opens on: naming it first switches nothing
// The page number each of those sections restarts at, in the same order (null = it
// counts on). ODF writes it on the paragraph that switches master page.
masterPageStarts: (number | null)[];
@@ -421,7 +422,9 @@ function shapeFill(gp: PropMap, defaultSolid: boolean): string | null {
// per-shape border) wins over draw:stroke. Like the fill, a drawn shape's stroke defaults
// to solid when draw:stroke is absent (color from svg:stroke-color); "none" turns it off.
function shapeStroke(gp: PropMap, defaultSolid: boolean): { color: string | null; widthPt: number | null } {
const border = gp['fo:border'];
// A drawn shape's outline is draw:stroke alone: its style chain ends in LibreOffice's
// Frame style, whose hairline fo:border belongs to text frames.
const border = defaultSolid ? undefined : gp['fo:border'];
if (border !== undefined) {
let widthPt: number | null = null, color: string | null = null, styleTok: string | null = null;
for (const part of border.trim().split(/\s+/)) {
@@ -799,7 +802,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
const first = resolver.hasMasterPage(masters.leading) ? masters.leading : null;
const geo = resolver.pageGeometry(first) ?? resolver.pageGeometry();
const contentWidthCm = contentWidthOf(geo);
const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), usedListStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, contentWidthCm, docContentWidthCm: contentWidthCm, pageRtl: geo?.rtl ?? false, masterPages: [], masterPageStarts: [], bodyBlocks: 0, openBookmarks: new Set(), openComments: new Map(), commentReplies: odfCommentReplies(body), revisions: odfRevisions(body), openInsertions: new Map(), notes: [], foldMarks: false };
const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), usedListStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, contentWidthCm, docContentWidthCm: contentWidthCm, pageRtl: geo?.rtl ?? false, masterPages: [], leadingMaster: masters.leading ?? 'Standard', masterPageStarts: [], bodyBlocks: 0, openBookmarks: new Set(), openComments: new Map(), commentReplies: odfCommentReplies(body), revisions: odfRevisions(body), openInsertions: new Map(), notes: [], foldMarks: false };
let blocks = convertBlocks(Array.from(body.children), ctx, 'body');
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
pairAlignedFrames(blocks, Math.floor(cmToPx(contentWidthCm)));
@@ -1338,7 +1341,9 @@ function convertToc(el: Element, ctx: Ctx, indexKind: IndexKind): Node {
// the paragraph style its entries use. Word and LibreOffice both put it short of the
// text width in some templates, and the number then hangs 45mm out of place.
const templates = Array.from(source?.getElementsByTagNameNS(NS.text, `${family}-entry-template`) ?? []);
const template = templates[0];
// LibreOffice opens an alphabetical index with its separator template (the letter
// rows), which names no page number; the first entry level says whether the rows do.
const template = templates.find((t) => t.getAttributeNS(NS.text, 'outline-level') !== 'separator') ?? templates[0];
const styled = ctx.resolver.tabStops(template?.getAttributeNS(NS.text, 'style-name') ?? null);
let tabPosCm = lengthToCm(stop?.getAttributeNS(NS.style, 'position'))
?? [...styled].reverse().find(t => t.align === 'right')?.pos ?? null;
@@ -1810,7 +1815,7 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault =
// its own header/footer; the block that does it opens that section, and from it on
// the blocks measure against that master's text width.
const master = kind === 'body' ? resolver.masterPageOf(styleName) : null;
const opensSection = !!master && ctx.masterPages[ctx.masterPages.length - 1] !== master;
const opensSection = !!master && master !== (ctx.masterPages[ctx.masterPages.length - 1] ?? ctx.leadingMaster);
if (opensSection) {
ctx.masterPages.push(master!);
// The same paragraph carries the number the section restarts at, if it does.
@@ -2447,6 +2452,19 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl
continue;
}
}
// LibreOffice's content control (loext): ours is a placeholder field, any other
// is read through, its text kept.
if (e.localName === 'content-control' && e.namespaceURI === (NS.loext as string)) {
if (e.getAttributeNS(NS.loext, 'tag') === 'edentext-placeholder') {
const field: Node = { type: 'placeholderField', attrs: { text: (e.textContent ?? '').trim() } };
const marks = marksFor(props, ctx.resolver, defaults);
if (marks.length) field.marks = marks;
out.push(field);
} else {
for (const n of convertInline(e, ctx, props, defaults, hfFields)) out.push(n);
}
continue;
}
if (e.namespaceURI === NS.draw) {
const conv = convertDrawElement(e, ctx);
// The one-paragraph zone flows as-char images; a positioned frame is out of
+8 -1
View File
@@ -708,7 +708,14 @@ export class StyleResolver {
? (this.pageUsage(rest) === 'left' ? rest : this.pageUsage(twin) === 'left' ? twin : null)
: null;
if (leftPage === rest) rest = twin;
const handover = pair ? null : successor;
// A hand-over whose one page shows the very zones its successor runs (the left ones
// on a left page) opens the section on that side; only other zones make a first page.
const sig = (el: Element | null) => (el
? Array.from(el.childNodes, (n) => new XMLSerializer().serializeToString(n)).join('').replace(/ (?:text|style):style-name="[^"]*"/g, '')
: '');
const sameAsRest = (local: string) => sig(zoneIn(mp, local))
=== sig((this.pageUsage(mp) === 'left' ? zoneIn(rest, `${local}-left`) : null) ?? zoneIn(rest, local));
const handover = pair || (successor && sameAsRest('header') && sameAsRest('footer')) ? null : successor;
const zone = (local: string) => zoneIn(rest, local);
const firstZone = (local: string): Element | null =>
+2
View File
@@ -361,6 +361,8 @@ const BY_PRST = new Map([...NAMED.map((k) => [SHAPES[k].prst, k] as const), ['st
export function shapeFromOdfType(type: string | null | undefined): ShapeKind | null {
if (!type) return 'textbox';
if (type === 'circle') return 'ellipse';
// LibreOffice keeps a DOCX preset under its OOXML name ("ooxml-rect").
if (type.startsWith('ooxml-')) return shapeFromPrst(type.slice(6));
return BY_ODF.get(type) ?? null;
}
+6
View File
@@ -253,6 +253,12 @@
text-overflow: ellipsis;
flex: 0 1 auto;
}
/* A row with no page number to keep in place (a bibliography) wraps its text instead. */
.paper .tiptap .toc-nopage .toc-text {
white-space: normal;
overflow: visible;
text-overflow: clip;
}
/* Leader dots filling the gap: the row grants the span the space that is left and
clips the dots that do not fit, so the page number stays put. */
.paper .tiptap .toc-leader {
+6 -2
View File
@@ -101,9 +101,13 @@ describe('DOCX export → import round trip', () => {
// Word wants the page geometry repeated per sectPr.
expect(documentXml.match(/<w:pgSz/g)!.length).toBe(3);
expect(documentXml.match(/<w:pgMar/g)!.length).toBe(3);
// The body-final sectPr (the one the importer reads) still references the header.
// The section's first sectPr references the header; the later column groups of the
// same section name none and link to it (a reference of their own would make
// LibreOffice switch page styles there, with a page break).
const firstSect = documentXml.slice(documentXml.indexOf('<w:sectPr'), documentXml.indexOf('</w:sectPr>'));
expect(firstSect).toContain('<w:headerReference');
const finalSect = documentXml.slice(documentXml.lastIndexOf('<w:sectPr'));
expect(finalSect).toContain('<w:headerReference');
expect(finalSect).not.toContain('<w:headerReference');
});
it('coalesces adjacent equal-attr fragments (columnsFlow page splits) into one section', async () => {
+2 -2
View File
@@ -514,8 +514,8 @@ describe.skipIf(!SOFFICE)('LibreOffice round-trip (needs soffice on PATH)', () =
const title = style('Title');
check('LO resolve: Title inherits from Heading', /style:parent-style-name="Heading"/.test(title), title);
check('LO resolve: Title is 28pt', /fo:font-size="28pt"/.test(title), title);
// w:docDefaults arrives as the style LibreOffice mints for Word's defaults.
const defaults = style('Standard_20__28_WW_29_');
// w:docDefaults arrive on Standard itself, the style LibreOffice maps "Normal" onto.
const defaults = style('Standard');
check('LO resolve: the default font arrives', /Times New Roman/.test(defaults), defaults);
check('LO resolve: the default size arrives', /fo:font-size="12pt"/.test(defaults), defaults);
});