diff --git a/docs/showcase/book-spread.png b/docs/showcase/book-spread.png index d13d809..7524efe 100644 Binary files a/docs/showcase/book-spread.png and b/docs/showcase/book-spread.png differ diff --git a/docs/showcase/book.docx b/docs/showcase/book.docx index 7f0c970..19a20bc 100644 Binary files a/docs/showcase/book.docx and b/docs/showcase/book.docx differ diff --git a/docs/showcase/book.odt b/docs/showcase/book.odt index a512f38..6c2e4c6 100644 Binary files a/docs/showcase/book.odt and b/docs/showcase/book.odt differ diff --git a/docs/showcase/book.png b/docs/showcase/book.png index 30ee816..fd51dff 100644 Binary files a/docs/showcase/book.png and b/docs/showcase/book.png differ diff --git a/docs/showcase/newsletter.docx b/docs/showcase/newsletter.docx index 94da4f0..80f42ad 100644 Binary files a/docs/showcase/newsletter.docx and b/docs/showcase/newsletter.docx differ diff --git a/docs/showcase/newsletter.odt b/docs/showcase/newsletter.odt index 58cb540..024ece1 100644 Binary files a/docs/showcase/newsletter.odt and b/docs/showcase/newsletter.odt differ diff --git a/docs/showcase/thesis-contents.png b/docs/showcase/thesis-contents.png index c0d4ecb..b5e0357 100644 Binary files a/docs/showcase/thesis-contents.png and b/docs/showcase/thesis-contents.png differ diff --git a/docs/showcase/thesis-dark.png b/docs/showcase/thesis-dark.png index fff096d..4ec4fb9 100644 Binary files a/docs/showcase/thesis-dark.png and b/docs/showcase/thesis-dark.png differ diff --git a/docs/showcase/thesis-overview.png b/docs/showcase/thesis-overview.png index 9aa30a3..0866bc7 100644 Binary files a/docs/showcase/thesis-overview.png and b/docs/showcase/thesis-overview.png differ diff --git a/docs/showcase/thesis-review.docx b/docs/showcase/thesis-review.docx index f795c1b..2c33798 100644 Binary files a/docs/showcase/thesis-review.docx and b/docs/showcase/thesis-review.docx differ diff --git a/docs/showcase/thesis-review.odt b/docs/showcase/thesis-review.odt index 791c1f0..d14b74b 100644 Binary files a/docs/showcase/thesis-review.odt and b/docs/showcase/thesis-review.odt differ diff --git a/docs/showcase/thesis-review.png b/docs/showcase/thesis-review.png index 3c15a0b..7fdfcaf 100644 Binary files a/docs/showcase/thesis-review.png and b/docs/showcase/thesis-review.png differ diff --git a/docs/showcase/thesis.docx b/docs/showcase/thesis.docx index 7bc7ad4..c554849 100644 Binary files a/docs/showcase/thesis.docx and b/docs/showcase/thesis.docx differ diff --git a/docs/showcase/thesis.odt b/docs/showcase/thesis.odt index 4d8394d..7f805fa 100644 Binary files a/docs/showcase/thesis.odt and b/docs/showcase/thesis.odt differ diff --git a/docs/showcase/thesis.png b/docs/showcase/thesis.png index 7656a91..4ab6a63 100644 Binary files a/docs/showcase/thesis.png and b/docs/showcase/thesis.png differ diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index 878744f..4e5e929 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -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, diff --git a/src/lib/editor/extensions/CLAUDE.md b/src/lib/editor/extensions/CLAUDE.md index abcb970..d4d7be7 100644 --- a/src/lib/editor/extensions/CLAUDE.md +++ b/src/lib/editor/extensions/CLAUDE.md @@ -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. diff --git a/src/lib/editor/extensions/notes.ts b/src/lib/editor/extensions/notes.ts index 5c79614..f2b65e5 100644 --- a/src/lib/editor/extensions/notes.ts +++ b/src/lib/editor/extensions/notes.ts @@ -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({ 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({ // 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({ }, }); +// 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; } diff --git a/src/lib/editor/extensions/tableOfContents.ts b/src/lib/editor/extensions/tableOfContents.ts index df8beb4..194af24 100644 --- a/src/lib/editor/extensions/tableOfContents.ts +++ b/src/lib/editor/extensions/tableOfContents.ts @@ -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'); diff --git a/src/lib/editor/extensions/textBox.ts b/src/lib/editor/extensions/textBox.ts index f124670..83835ce 100644 --- a/src/lib/editor/extensions/textBox.ts +++ b/src/lib/editor/extensions/textBox.ts @@ -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; diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index 006ca85..512c1c1 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -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 ``** (`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. diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 2a103b9..cbbe11c 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -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('')) continue; const sorted = xml.replace(/([\s\S]*?)<\/w:pBdr>/g, (_m, inner: string) => { const sides = inner.match(/]*\/>/g) ?? []; if (sides.join('') !== inner) return _m; const rank = (s: string) => PBDR_ORDER.indexOf(/^<(w:\w+)/.exec(s)![1]); return `${[...sides].sort((a, b) => rank(a) - rank(b)).join('')}`; - }); + }).replace(/(]*>\s*(?:PAGE|NUMPAGES)\s*<\/w:instrText>)()/g, '$11$2'); if (sorted === xml) continue; files[path] = strToU8(sorted); changed = true; @@ -1999,9 +2001,9 @@ const DOCX_NUM_FMT: Record = { '1': 'decimal', a: 'lowerLetter', A: 'upperLetter', i: 'lowerRoman', I: 'upperRoman', }; -// Post-pack pass: the document-wide /, which the docx -// package does not expose. Word keeps its defaults in settings.xml, beside -// w:mirrorMargins. +// Post-pack pass: /, 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(/(]*>)/, `$1${block('footnote')}${block('endnote')}`), ); + const docBytes = files['word/document.xml']; + if (docBytes) { + files['word/document.xml'] = strToU8(strFromU8(docBytes).replace( + /]*>(?:]*\/>)*/g, (m) => `${m}${block('footnote')}${block('endnote')}`)); + } const out: Record = {}; 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 { // 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('', snippets.join('') + '')); + // 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('', '') + .replace('', snippets.join('') + '')); const out: Record = {}; 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)))); } diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index a564977..062082a 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -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(/]*>[\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(/]*>[\s\S]*?<\/text:p>/g, (block) => block.replace(/\n/g, '').replace(/\t/g, '')); styles = styles.split(LBR).join(''); styles = styles.replace(new RegExp(`${PGC}(\\d*)${PGC}`, 'g'), '$1'); 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 `${body}`; + 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 `${body}`; } // 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:name="${layout}"[\\s\\S]*?`).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 ? `` : ``); - 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(/]*>/, (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('', `${layouts.join('')}`); if (pages.length) styles = styles.replace('', `${pages.join('')}`); diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index 2773957..0299613 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -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 diff --git a/src/lib/import/docxStyles.ts b/src/lib/import/docxStyles.ts index 9ef8db9..8f6b6e8 100644 --- a/src/lib/import/docxStyles.ts +++ b/src/lib/import/docxStyles.ts @@ -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 { - 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). diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 0c1a7e1..46e0192 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -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 diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index 85fc2a8..60c8b2d 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -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 => diff --git a/src/lib/utils/shapes.ts b/src/lib/utils/shapes.ts index de48799..ba6733a 100644 --- a/src/lib/utils/shapes.ts +++ b/src/lib/utils/shapes.ts @@ -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; } diff --git a/src/styles/editor.css b/src/styles/editor.css index 6f13af5..61340a7 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -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 { diff --git a/tests/docx-roundtrip.test.ts b/tests/docx-roundtrip.test.ts index 6dd4cf8..16f98ab 100644 --- a/tests/docx-roundtrip.test.ts +++ b/tests/docx-roundtrip.test.ts @@ -101,9 +101,13 @@ describe('DOCX export → import round trip', () => { // Word wants the page geometry repeated per sectPr. expect(documentXml.match(/')); + expect(firstSect).toContain(' { diff --git a/tests/lo-roundtrip.test.ts b/tests/lo-roundtrip.test.ts index d13e522..7e14f3a 100644 --- a/tests/lo-roundtrip.test.ts +++ b/tests/lo-roundtrip.test.ts @@ -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); });