diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index f44c6b7..455d718 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -242,7 +242,8 @@ function inlineToRuns(content: TiptapNode[] = [], forceBold = false): Inline[] { if (href) out.push(new ExternalHyperlink({ link: String(href), children: runs })); else out.push(...runs); } else if (node.type === 'hardBreak') { - out.push(new TextRun({ break: 1 })); + // Carry the run's props so an empty line between two breaks keeps its font size. + out.push(new TextRun({ break: 1, ...runPropsFromMarks(node.marks) })); } else if (node.type === 'image') { const img = imageRun(node); if (img) out.push(img); @@ -424,7 +425,7 @@ function txbxParagraphXml(node: TiptapNode, indentTwip = 0, markerText = ''): st }); runs += `${rPr}${inner}`; } else if (child.type === 'hardBreak') { - runs += ''; + runs += `${txbxRunPropsXml(child.marks)}`; } } return `${pPr.length ? `${pPr.join('')}` : ''}${runs}`; diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index dc6b121..da365ed 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -174,7 +174,11 @@ function replaceHardBreaks(node: TiptapNode): TiptapNode { if (!node.content?.length) return node; return { ...node, - content: node.content.map(c => c.type === 'hardBreak' ? { type: 'text', text: LBR } : replaceHardBreaks(c)), + content: node.content.map(c => + c.type === 'hardBreak' + ? { type: 'text', text: LBR, ...(c.marks ? { marks: c.marks } : {}) } + : replaceHardBreaks(c), + ), }; } @@ -881,6 +885,39 @@ function applyOrderedListFormats(odtBytes: Uint8Array, formats: (OlStyleFix | nu return rezipOdt(files); } +// One slot per top-level list (matching odf-kit's L# order): an ordered list's +// start value when >1 (a continued Word list), else null. +function collectListStartValues(node: TiptapNode, result: (number | null)[]): void { + for (const child of node.content ?? []) { + if (child.type === 'bulletList') { + result.push(null); + } else if (child.type === 'orderedList') { + const s = child.attrs?.start; + result.push(typeof s === 'number' && s > 1 ? s : null); + } + } +} + +// odf-kit drops the ordered-list `start` attr, so a list continued across an intervening +// paragraph would restart at 1. Add text:start-value to each such list's first item. +function applyListStartValues(odtBytes: Uint8Array, starts: (number | null)[]): Uint8Array { + if (starts.every(s => s === null)) return odtBytes; + + const files = unzipSync(odtBytes); + const contentBytes = files['content.xml']; + if (!contentBytes) return odtBytes; + + let content = strFromU8(contentBytes); + starts.forEach((start, i) => { + if (start == null) return; + const re = new RegExp(`(]*>\\s*`); + content = content.replace(re, `$1 text:start-value="${start}">`); + }); + + files['content.xml'] = strToU8(content); + return rezipOdt(files); +} + // Effective marker char of a bullet list at a 1-based depth: its bulletChar attr, // else the default cycle. function effBulletChar(list: TiptapNode, depth: number): string { @@ -1325,15 +1362,13 @@ function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; countr ']*?)/>`), - `$1>`, - ); - } + // odf-kit's Standard style carries fo:margin-bottom="0.212cm", but the editor (like + // LibreOffice and Word) has no paragraph spacing by default — every paragraph and list + // item inherits this, so zero it or the export gains ~6pt per block vs. the preview. + styles = styles.replace( + /(]*?)fo:margin-bottom="[^"]*"/, + `$1fo:margin-bottom="0cm"`, + ); // Scope each rewrite to its own … block so the // font-size/margin replacements never bleed across heading levels. @@ -1922,7 +1957,7 @@ function contentsEntryStyle(name: string, level: number, tabPosCm: number): stri function contentsHeadingStyle(): string { return ( `` + - `` + + `` + `` ); } @@ -2110,6 +2145,12 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU collectOrderedListFormats(raw, olFormats); let numberedOdt = applyOrderedListFormats(odt as Uint8Array, olFormats); + // A Word list continued across an intervening paragraph splits into separate nodes with + // a `start` attr; odf-kit drops it, so emit text:start-value on each such list's first item. + const listStarts: (number | null)[] = []; + collectListStartValues(raw, listStarts); + numberedOdt = applyListStartValues(numberedOdt, listStarts); + // Rewrite odf-kit's default bullet chars into the per-level bulletChar attrs. const blChars: (string[] | null)[] = []; collectBulletListChars(raw, blChars); diff --git a/src/lib/export/pdf.ts b/src/lib/export/pdf.ts index b964ee7..11bb27f 100644 --- a/src/lib/export/pdf.ts +++ b/src/lib/export/pdf.ts @@ -446,8 +446,8 @@ function printCss(o: PrintPdfOptions): string { html, body { margin: 0; padding: 0; background: #fff; } .paper { width: auto !important; transform: none !important; box-shadow: none !important; background: none !important; } .paper .tiptap { padding: 0 !important; min-height: 0 !important; background: #fff !important; box-shadow: none !important; color: #000 !important; } -/* generateHTML drops TipTap's .tableWrapper, whose margin gives a table its bottom gap. */ -.paper .tiptap table { margin: 0 0 0.212cm; } +/* generateHTML drops TipTap's .tableWrapper; mirror its (zero) margin. */ +.paper .tiptap table { margin: 0; } .paper .tiptap tr { break-inside: avoid; } .paper .tiptap [data-color] { color: var(--font-color, currentColor) !important; } /* Manual page break (breakBefore) → native page break; never on the first block (no blank leading page). */ diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 8921eda..ef23972 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -34,6 +34,7 @@ type Ctx = { imageCache: Map; convertedImages: ConvertedImages; pendingBlocks: Node[]; + listCounters: Map>; // numId → ilvl → last number used }; // ---- units & editor defaults to suppress ----------------------------------- @@ -100,7 +101,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages = const styles = new DocxStyles(stylesDoc, numberingDoc, themeDoc); const warnings = new Set(); - const ctx: Ctx = { styles, warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [] }; + const ctx: Ctx = { styles, warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map() }; const body = docDoc.getElementsByTagNameNS(W, 'body')[0]; if (!body) throw new Error('Not a Word document (no w:body).'); @@ -111,12 +112,19 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages = const { groups, midSectPrs } = splitBodySections(Array.from(body.children)); const finalSectPr = fc(body, 'sectPr'); const blocks: Node[] = []; - for (const g of groups) { + groups.forEach((g, gi) => { const inner = convertBlocks(g.els, ctx, 'body'); + // A section's own w:type says how it begins: a page-starting break (nextPage/odd/even, + // or the default) puts its first block on a new page; continuous/nextColumn flow on. + if (gi > 0 && inner.length && sectionStartsNewPage(g.sectPr ?? finalSectPr)) { + const first = inner[0]; + if (first.type === 'paragraph' || first.type === 'heading') + first.attrs = { ...(first.attrs ?? {}), breakBefore: 'page' }; + } const cols = sectPrColumns(g.sectPr ?? finalSectPr, ctx); if (cols) pushColumnRuns(inner, cols, blocks, ctx); else blocks.push(...inner); - } + }); if (blocks.length === 0) blocks.push({ type: 'paragraph' }); let sect = parseSectPr(finalSectPr, ctx); @@ -303,7 +311,14 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef stack.push({ ilvl, numId: num.numId, list: makeListNode(ctx, num.numId, ilvl) }); if (ilvl === num.ilvl) break; } - stack[stack.length - 1].list.content!.push({ type: 'listItem', content: [para] }); + const targetList = stack[stack.length - 1].list; + const number = nextListNumber(ctx, num.numId, num.ilvl); + // A non-list paragraph splits one Word list into separate nodes here; carry the + // running count onto a fresh ordered node so numbering continues, not restarts at 1. + if (targetList.type === 'orderedList' && targetList.content!.length === 0 && number > 1) { + targetList.attrs = { ...(targetList.attrs ?? {}), start: number }; + } + targetList.content!.push({ type: 'listItem', content: [para] }); } else { flush(); const { blocks, trailingBreak } = splitParaAtPageBreaks(convertParagraph(el, ctx, kind, boldByDefault), kind); @@ -339,6 +354,19 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef return out; } +// Word numbering is document-global per numId: a level keeps counting across intervening +// non-list paragraphs (which split the list into separate nodes here) and resets its deeper +// levels whenever a shallower level advances. Returns this item's number. +function nextListNumber(ctx: Ctx, numId: number, ilvl: number): number { + let per = ctx.listCounters.get(numId); + if (!per) { per = new Map(); ctx.listCounters.set(numId, per); } + const cur = per.get(ilvl); + const n = cur == null ? (ctx.styles.level(numId, ilvl).start ?? 1) : cur + 1; + per.set(ilvl, n); + for (const l of Array.from(per.keys())) if (l > ilvl) per.delete(l); + return n; +} + function paragraphNum(el: Element, ctx: Ctx): { numId: number; ilvl: number } | null { const ppr = fc(el, 'pPr'); let np: { numId: number; ilvl: number } | null = null; @@ -449,7 +477,7 @@ function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault: // Headings keep their em-based editor defaults (direct-only), so skip style resolution. const styleSpacing = level == null ? ctx.styles.paragraphSpacing(pStyle ? wVal(pStyle) : null) : {}; const attrs = blockAttrs(ppr, kind, level, jcVal, styleSpacing); - const baseRun = mergeRunProps(ctx.styles.defaultRun(), ctx.styles.styleOwn(pStyle ? wVal(pStyle) : null)); + const baseRun = ctx.styles.paragraphRun(pStyle ? wVal(pStyle) : null); const content = convertInline(el, ctx, baseRun, level, false, boldByDefault); // An empty line's height comes from the paragraph mark's own run props (w:pPr/w:rPr), @@ -499,9 +527,9 @@ function headingLevelOf(ppr: Element | null, ctx: Ctx): number | null { return null; } -// Spacing = the paragraph style's own w:spacing (styleSpacing, resolved by the caller) -// overridden per-attribute by DIRECT w:pPr; indent comes from direct w:pPr only. -// docDefaults stay the editor's defaults. jcVal is resolved through the chain by the caller. +// Spacing = the style chain's w:spacing (styleSpacing, resolved by the caller) overridden +// per-attribute by DIRECT w:pPr; indent comes from direct w:pPr only. jcVal is resolved +// through the chain by the caller. function blockAttrs(ppr: Element | null, kind: BlockKind, headingLevel: number | null, jcVal: string | null, styleSpacing: ParaSpacing): Record { const attrs: Record = {}; @@ -514,6 +542,8 @@ function blockAttrs(ppr: Element | null, kind: BlockKind, headingLevel: number | const after = (sp ? intAttr(sp, W, 'after') : null) ?? styleSpacing.after ?? null; const line = (sp ? intAttr(sp, W, 'line') : null) ?? styleSpacing.line ?? null; const rule = (sp && sp.getAttributeNS(W, 'lineRule')) || styleSpacing.lineRule || null; + // An attribute no layer sets is Word's implied 0, which is also the editor's paragraph + // default (editor.css) — so it stays unset rather than accreting an explicit 0. if (before != null) attrs.spaceBefore = snapPt(twipToPt(before)); if (after != null) attrs.spaceAfter = snapPt(twipToPt(after)); if (line != null && (!rule || rule === 'auto')) { @@ -565,7 +595,13 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, headingLevel: nu const rPr = fc(r, 'rPr'); const rStyle = fc(rPr, 'rStyle'); const props = mergeRunProps(mergeRunProps(baseRun, ctx.styles.styleOwn(rStyle ? wVal(rStyle) : null)), parseRunProps(rPr)); - if (!props.font && props.fontTheme) props.font = ctx.styles.themeFont(props.fontTheme); + // No font resolved anywhere: Word's implicit body default is the theme's minor font, so + // fall back to the document's own theme (not the editor default). Headings keep the editor + // heading default, so only body text falls back. + if (!props.font) { + const theme = props.fontTheme ?? (headingLevel == null ? 'minor' : null); + if (theme) props.font = ctx.styles.themeFont(theme); + } const marks = marksFor(props, headingLevel, boldByDefault, !!linkHref); if (linkHref) marks.push({ type: 'link', attrs: { href: linkHref } }); return marks; @@ -625,8 +661,8 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, headingLevel: nu case 'instrText': if (fieldMode === 'instr') fieldInstr += child.textContent ?? ''; break; case 't': if (!skipResult()) pushText(child.textContent ?? '', marks); break; case 'tab': if (!skipResult()) pushText('\t', marks); break; - case 'br': out.push(child.getAttributeNS(W, 'type') === 'page' ? { type: PB_MARKER } : { type: 'hardBreak' }); break; - case 'cr': out.push({ type: 'hardBreak' }); break; + case 'br': out.push(child.getAttributeNS(W, 'type') === 'page' ? { type: PB_MARKER } : hardBreakNode(marks)); break; + case 'cr': out.push(hardBreakNode(marks)); break; case 'drawing': pushDrawn(convertDrawing(child, ctx)); break; case 'pict': pushDrawn(convertPict(child, ctx)); break; } @@ -699,6 +735,14 @@ function mergeAdjacentText(nodes: Node[]): Node[] { return out; } +// A hardBreak carries its run's marks so an empty line between two breaks keeps the run's +// font size (Word/LibreOffice render such a line at that size, not the paragraph default). +function hardBreakNode(marks: Mark[]): Node { + const n: Node = { type: 'hardBreak' }; + if (marks.length) n.marks = marks; + return n; +} + function marksFor(props: RunProps, headingLevel: number | null, boldByDefault: boolean, inLink: boolean): Mark[] { const marks: Mark[] = []; const textStyle: Record = {}; @@ -1036,6 +1080,14 @@ function splitBodySections(children: Element[]): { return { groups, midSectPrs }; } +// A section break's w:type: 'continuous'/'nextColumn' flow within the page; anything +// else (nextPage/oddPage/evenPage, or absent = the default nextPage) begins a new page. +function sectionStartsNewPage(sectPr: Element | null): boolean { + const t = fc(sectPr, 'type'); + const val = t ? wVal(t) : null; + return val !== 'continuous' && val !== 'nextColumn'; +} + // A sectPr's w:cols → columns attrs when it declares more than one column. function sectPrColumns(sectPr: Element | null, ctx: Ctx): { count: number; gapCm: number } | null { const cols = fc(sectPr, 'cols'); @@ -1136,7 +1188,7 @@ function convertHfPart(relId: string | null, ctx: Ctx): HfDoc { const ta = (fc(ppr, 'jc') ? wVal(fc(ppr, 'jc')!) : null) ?? ''; textAlign = ta === 'center' || ta === 'both' ? (ta === 'both' ? 'justify' : 'center') : ta === 'right' || ta === 'end' ? 'right' : ''; } - const baseRun = mergeRunProps(hfCtx.styles.defaultRun(), hfCtx.styles.styleOwn(fc(ppr, 'pStyle') ? wVal(fc(ppr, 'pStyle')!) : null)); + const baseRun = hfCtx.styles.paragraphRun(fc(ppr, 'pStyle') ? wVal(fc(ppr, 'pStyle')!) : null); inline.push(...convertInline(p, hfCtx, baseRun, null, true, false).filter((n) => n.type !== PB_MARKER)); } while (inline[0]?.type === 'hardBreak') inline.shift(); diff --git a/src/lib/import/docxStyles.ts b/src/lib/import/docxStyles.ts index 31175af..d1baa3f 100644 --- a/src/lib/import/docxStyles.ts +++ b/src/lib/import/docxStyles.ts @@ -99,6 +99,7 @@ export class DocxStyles { private ownSpacing = new Map(); // style's own w:pPr/w:spacing private defaultParaStyle: string | null = null; // the w:default="1" paragraph style private defaultsAlign: string | null = null; // docDefaults w:pPrDefault/w:jc + private defaultsSpacing: ParaSpacing = {}; // docDefaults w:pPrDefault/w:spacing private numToAbstract = new Map(); private abstractLevels = new Map>(); private minorFont?: string; // theme1.xml body font (e.g. Calibri) @@ -132,6 +133,8 @@ export class DocxStyles { const pPr = defs.getElementsByTagNameNS(W, 'pPrDefault')[0]?.getElementsByTagNameNS(W, 'pPr')[0]; const ddJc = pPr ? firstChild(pPr, 'jc') : null; if (ddJc) this.defaultsAlign = wVal(ddJc); + const ddSp = pPr ? firstChild(pPr, 'spacing') : null; + if (ddSp) this.defaultsSpacing = readSpacing(ddSp); } for (const style of Array.from(doc.getElementsByTagNameNS(W, 'style'))) { const id = style.getAttributeNS(W, 'styleId'); @@ -196,8 +199,11 @@ export class DocxStyles { return merged; } - defaultRun(): RunProps { - return { ...this.defaultsRun }; + // A paragraph's run baseline: docDefaults ← its paragraph style's basedOn chain. With no + // w:pStyle the w:default="1" paragraph style (Word's "Normal") applies — it usually + // carries the document's real body font, which docDefaults alone gets wrong. + paragraphRun(pStyleId: string | null | undefined): RunProps { + return mergeRunProps(this.defaultsRun, this.styleOwn(pStyleId ?? this.defaultParaStyle)); } styleNumPr(styleId: string | null | undefined): { numId: number; ilvl: number } | null { @@ -237,7 +243,7 @@ export class DocxStyles { } // The style's effective spacing along the w:basedOn chain (root → leaf, leaf wins per - // attribute). Excludes docDefaults on purpose: those stay the editor's own defaults. + // attribute). private styleSpacing(styleId: string | null | undefined, seen = new Set()): ParaSpacing { if (!styleId || seen.has(styleId)) return {}; seen.add(styleId); @@ -245,12 +251,11 @@ export class DocxStyles { return { ...base, ...(this.ownSpacing.get(styleId) ?? {}) }; } - // Effective paragraph spacing from styles only (direct w:pPr/w:spacing wins in the - // caller): the pStyle's basedOn chain, else the default paragraph style. Honors an - // explicit style like "No Spacing" (after=0) that the editor default would otherwise - // override. docDefaults are deliberately left as the editor's defaults. + // Effective paragraph spacing from styles only (direct w:pPr/w:spacing wins in the caller): + // docDefaults ← the pStyle's basedOn chain, or the default paragraph style when there is no + // w:pStyle. An attribute no layer sets is Word's implied 0 (applied in blockAttrs). paragraphSpacing(pStyleId: string | null | undefined): ParaSpacing { - return this.styleSpacing(pStyleId ?? this.defaultParaStyle); + return { ...this.defaultsSpacing, ...this.styleSpacing(pStyleId ?? this.defaultParaStyle) }; } level(numId: number, ilvl: number): LevelDef { diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index bd82ba1..4e4c6a9 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -45,8 +45,8 @@ export interface OdtImportResult { warnings: string[]; } -// 'list' zeroes the default bottom margin (export rewrites List_20_* to 0cm to -// match the editor); body/cell paragraphs default to Standard's 0.212cm. +// Where a block sits: only 'body' takes page breaks/columns/TOCs, and a 'list' +// paragraph's indent lives in the list style rather than its own margin. type BlockKind = 'body' | 'list' | 'cell'; // `files` is the full unzipped archive so image converters can read Pictures/ @@ -281,8 +281,6 @@ function convertDrawElement(e: Element, ctx: Ctx): { inline?: Node; block?: Node // ---- editor defaults to suppress on import ----------------------------------- const BODY_FONT_SIZE_PT = 12; -// Standard paragraph style's fo:margin-bottom as odf-kit emits it. -const STD_MARGIN_BOTTOM_PT = lengthToPt('0.212cm')!; // Match LO/Word producer rounding noise (cm: ≤0.014pt, twips: ≤0.025pt), not // genuine user values. const EPS_PT = 0.15; @@ -627,10 +625,10 @@ function blockAttrs(paraProps: PropMap, textProps: PropMap, headingLevel: number } const defTop = hdef ? hdef.marginTopPt : 0; - const defBottom = hdef ? hdef.marginBottomPt : kind === 'list' ? 0 : STD_MARGIN_BOTTOM_PT; - // An unspecified fo:margin is ODF's default of 0 (not the editor's 6pt body - // default), so a foreign paragraph that omits it gets 0 spacing like - // Word/LibreOffice. Editor exports write the margins explicitly, so suppressed. + const defBottom = hdef ? hdef.marginBottomPt : 0; + // An unspecified fo:margin is ODF's default of 0, which is also the editor's + // paragraph default — so it is suppressed, and a file that does declare spacing + // (e.g. LO's 0.212cm Text Body) keeps it as an explicit value. const mt = lengthToPt(paraProps['fo:margin-top']) ?? 0; const mb = lengthToPt(paraProps['fo:margin-bottom']) ?? 0; if (Math.abs(mt - defTop) > EPS_PT) attrs.spaceBefore = snapPt(mt); @@ -774,9 +772,14 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel case 'tab': pushText('\t', props, linkHref); continue; - case 'line-break': - out.push({ type: 'hardBreak' }); + case 'line-break': { + // Carry the run's marks so an empty line between two breaks keeps its font size. + const marks = marksFor(props, ctx.resolver, headingLevel, boldByDefault); + const br: Node = { type: 'hardBreak' }; + if (marks.length) br.marks = marks; + out.push(br); continue; + } case 'a': { // ODF hyperlink → link mark on the contained text (internal #bookmark // links are kept as-is though the editor has no bookmark targets). diff --git a/src/lib/storage/pageMargins.ts b/src/lib/storage/pageMargins.ts index c7daf8f..8646ace 100644 --- a/src/lib/storage/pageMargins.ts +++ b/src/lib/storage/pageMargins.ts @@ -2,9 +2,9 @@ export type PageMargins = { top: number; bottom: number; left: number; right: nu const KEY = 'odf-editor-page-margins'; -// Match the editor's historical hardcoded layout: 2.54cm (96px) top/bottom, -// 2.12cm (≈80px) left/right. Keeps existing documents rendering/exporting unchanged. -export const DEFAULT_MARGINS: PageMargins = { top: 2.54, bottom: 2.54, left: 2.12, right: 2.12 }; +// LibreOffice Writer's default page margins (Word uses 2.54cm all round). Only a new +// document gets these — an imported one always adopts its own page geometry. +export const DEFAULT_MARGINS: PageMargins = { top: 2, bottom: 2, left: 2, right: 2 }; export const PX_PER_CM = 96 / 2.54; // 37.795 — A4 @96dpi export const cmToPx = (cm: number) => cm * PX_PER_CM; diff --git a/src/styles/editor.css b/src/styles/editor.css index 04d5e94..614b695 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -143,10 +143,12 @@ font-size: 0; } +/* No paragraph spacing by default, like LibreOffice's Default Paragraph Style and + Word's implied 0 — an editor default here would show up as extra space in every + imported document (export/odt.ts zeroes odf-kit's Standard style to match). */ .paper .tiptap p { margin-top: 0; - /* matches odf-kit Standard style fo:margin-bottom (0.212cm ≈ 6pt) */ - margin-bottom: 0.212cm; + margin-bottom: 0; } /* Hyperlinks (link.ts). Ctrl/Cmd+click opens them (Editor.svelte). */ @@ -391,7 +393,8 @@ .paper .tiptap ol { padding-left: 1.5rem; margin-top: 0; - margin-bottom: 0.212cm; + /* Matches the exported List_20_* styles, which inherit Standard's zeroed margin. */ + margin-bottom: 0; } /* Ordered-list numbering, keyed by data-eff-list-style — the effective numbering @@ -454,7 +457,8 @@ • padding → export emits fo:padding 0.1cm per cell (CELL_PADDING). Tables are wrapped in
by TipTap's node view. */ .paper .tiptap .tableWrapper { - margin: 0 0 0.212cm; + /* The exported table style carries no margin (nor do Word/LO tables). */ + margin: 0; overflow-x: auto; } diff --git a/tests/docx-roundtrip.test.ts b/tests/docx-roundtrip.test.ts index 23b6f59..0a41495 100644 --- a/tests/docx-roundtrip.test.ts +++ b/tests/docx-roundtrip.test.ts @@ -461,6 +461,35 @@ describe('DOCX import resolves theme fonts (Word default = Calibri)', () => { }); }); +describe('DOCX import falls back to the theme font when nothing references it', () => { + const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'; + const A = 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"'; + // No w:rFonts anywhere (not even a theme ref): Word still uses the theme minor font as the + // implicit body default. A heading must keep the editor default, not the theme major font. + const documentXml = ` + Body text + Heading text + `; + const themeXml = ` + + + `; + + const bytes = zipSync({ 'word/document.xml': strToU8(documentXml), 'word/theme/theme1.xml': strToU8(themeXml) }); + const doc = importDocx(bytes).content as N; + + it('applies the theme minor font to body text with no font of its own', () => { + const t = walk(doc.content![0], 'text')[0]; + expect(t.text).toBe('Body text'); + expect(markAttrs(t, 'textStyle')?.fontFamily).toBe('Calibri'); + }); + it('leaves a heading on the editor default (no theme major font)', () => { + const h = doc.content![1]; + expect(h.type).toBe('heading'); + expect(markAttrs(walk(h, 'text')[0], 'textStyle')?.fontFamily).toBeUndefined(); + }); +}); + describe('DOCX import detects headings by outline level (non-"HeadingN" style ids)', () => { const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'; // A heading style whose id is NOT "Heading1" — only its w:outlineLvl marks it a heading. @@ -690,3 +719,92 @@ describe('DOCX import of foreign multi-column sections', () => { expect(result.warnings).toContain('Tables and text boxes inside a multi-column layout were moved out of the columns'); }); }); + +describe('DOCX import: hardBreak carries its run font (empty-line height)', () => { + const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'; + // "Muster"

"Arbeitsvertrag", every run at 36pt (sz=72): the blank line + // between the two breaks must keep the 36pt height, like Word/LibreOffice. + const run = (inner: string) => `${inner}`; + const documentXml = ` + + ${run('Muster')}${run('')}${run('')}${run('Arbeitsvertrag')} + + `; + const bytes = zipSync({ 'word/document.xml': strToU8(documentXml) }); + + it('tags both hardBreaks with the run font size', () => { + const doc = importDocx(bytes).content as N; + const brs = walk(doc.content![0], 'hardBreak'); + expect(brs.length).toBe(2); + for (const br of brs) expect(markAttrs(br, 'textStyle')?.fontSize).toBe('36pt'); + }); + + it('keeps the break font size through a DOCX export round trip', async () => { + const doc = importDocx(bytes).content as N; + const round = importDocx(await buildDocx(doc as never)).content as N; + const brs = walk(round.content![0], 'hardBreak'); + expect(brs.length).toBe(2); + for (const br of brs) expect(markAttrs(br, 'textStyle')?.fontSize).toBe('36pt'); + }); +}); + +describe('DOCX import: a list continued across paragraphs keeps counting (same numId)', () => { + const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'; + const item = (numId: number, t: string) => + `${t}`; + const gap = (t: string) => `${t}`; + // numId 5 (upperRoman) split by body paragraphs; numId 6 shares the format but is its own + // counter, so it must restart — Word continues per numId, not per glyph style. + const documentXml = ` + ${item(5, 'One')}${gap('body a')}${item(5, 'Two')}${gap('body b')}${item(5, 'Three')}${item(6, 'Other')} + `; + const numberingXml = ` + + + + `; + const bytes = zipSync({ 'word/document.xml': strToU8(documentXml), 'word/numbering.xml': strToU8(numberingXml) }); + const doc = importDocx(bytes).content as N; + + it('emits separate ordered lists whose start values continue the numId', () => { + const ols = doc.content!.filter((n) => n.type === 'orderedList'); + expect(ols.length).toBe(4); + expect(ols.map((o) => o.attrs?.start)).toEqual([undefined, 2, 3, undefined]); + expect(ols.every((o) => o.attrs?.listStyleType === 'upper-roman')).toBe(true); + expect(walk(ols[3], 'text')[0].text).toBe('Other'); // separate numId → restarts at 1 + }); +}); + +describe('DOCX import: a mid-body section break starts a new page', () => { + const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'; + const geom = ''; + const build = (type: string | null) => { + const t = type ? `` : ''; + const documentXml = ` + section one + ${geom} + section two + ${t}${geom} + `; + return importDocx(zipSync({ 'word/document.xml': strToU8(documentXml) })).content as N; + }; + + it("marks the next section's first block with breakBefore for a default (nextPage) break", () => { + const top = build(null).content!; + expect(top.map((n) => n.type)).toEqual(['paragraph', 'paragraph']); + expect(walk(top[1], 'text')[0].text).toBe('section two'); + expect(top[0].attrs?.breakBefore).toBeFalsy(); + expect(top[1].attrs?.breakBefore).toBe('page'); + }); + + it('does not break for a continuous section', () => { + const top = build('continuous').content!; + expect(top[1].attrs?.breakBefore).toBeFalsy(); + }); + + it('keeps the section-break page break through a DOCX export round trip', async () => { + const round = importDocx(await buildDocx(build(null) as never)).content as N; + const two = round.content!.find((n) => walk(n, 'text').some((t) => t.text === 'section two'))!; + expect(two.attrs?.breakBefore).toBe('page'); + }); +}); diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 8dfbe2e..adae1a0 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -338,6 +338,39 @@ describe('Leg 1a: page format', () => { }); }); +describe('Leg 1a2: hardBreak font size (empty-line height)', () => { + it('round-trips a break run font so a blank line between two breaks keeps its size', async () => { + const fs36: N = { type: 'textStyle', attrs: { fontSize: '36pt' } }; + const br = (): N => ({ type: 'hardBreak', marks: [fs36] }); + const doc: N = { type: 'doc', content: [P(null, T('Muster', fs36), br(), br(), T('Arbeitsvertrag', fs36))] }; + const res = importOdt(await buildOdt(doc, margins, 'portrait')); + const brs: N[] = []; + (function walk(n: N) { if (n.type === 'hardBreak') brs.push(n); for (const c of n.content ?? []) walk(c); })(res.content); + check('both hardBreaks survive', brs.length === 2, brs.length); + const sized = brs.every((b) => b.marks?.some((m) => m.type === 'textStyle' && m.attrs?.fontSize === '36pt')); + check('both hardBreaks carry the 36pt run font', sized, JSON.stringify(brs)); + }); +}); + +describe('Leg 1a3: continued ordered-list start value', () => { + it('round-trips a start > 1 (odf-kit drops it) via text:start-value', async () => { + const olist = (start: number | null, t: string): N => ({ + type: 'orderedList', + attrs: { listStyleType: 'upper-roman', ...(start ? { start } : {}) }, + content: [{ type: 'listItem', content: [P(null, T(t))] }], + }); + const doc: N = { type: 'doc', content: [ + olist(null, 'First'), P(null, T('gap 1')), olist(2, 'Second'), P(null, T('gap 2')), olist(3, 'Third'), + ] }; + const bytes = await buildOdt(doc, margins, 'portrait'); + const content = strFromU8(unzipSync(bytes)['content.xml']); + check('content.xml carries text:start-value 2 and 3', /start-value="2"/.test(content) && /start-value="3"/.test(content), content.match(/text:start-value="\d+"/g)); + const round = importOdt(bytes).content; + const starts = (round.content ?? []).filter((n: N) => n.type === 'orderedList').map((n: N) => n.attrs?.start ?? null); + check('re-imported starts continue (1, 2, 3)', JSON.stringify(starts) === JSON.stringify([null, 2, 3]), starts); + }); +}); + describe('Leg 1b: merged table cells (colspan/rowspan)', () => { // 3×3 grid: A spans 2 cols (row 0); C spans 2 rows (col 0, rows 1–2). // row0: [A A][B] row1: [C][D][E] row2: [C][F][G] @@ -542,8 +575,8 @@ describe('Leg 2: foreign (LibreOffice/Word-style) .odt → importOdt', () => { check('foreign: Letter stays portrait', f.orientation === 'portrait'); check('foreign: plain run has no marks', c[0].content![0].marks === undefined, c[0]); - // Omitted fo:margin-bottom → ODF default 0, not the editor's 6pt body default. - check('foreign: omitted margin → spaceAfter 0', c[0].attrs?.spaceAfter === 0, c[0].attrs); + // Omitted fo:margin-bottom → ODF's default 0, which is the editor's default too, so no attr. + check('foreign: omitted margin → no spaceAfter', c[0].attrs?.spaceAfter == null, c[0].attrs); const p1 = c[1]; check('foreign: P1 align end → right', p1.attrs?.textAlign === 'right', p1.attrs); diff --git a/tests/unit/default-spacing.test.ts b/tests/unit/default-spacing.test.ts new file mode 100644 index 0000000..b11d8b0 --- /dev/null +++ b/tests/unit/default-spacing.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { unzipSync, strFromU8 } from 'fflate'; +import { buildOdt } from '../../src/lib/export/odt'; +import { DEFAULT_MARGINS } from '../../src/lib/storage/pageMargins'; + +// The editor renders no paragraph spacing (editor.css), like LibreOffice and Word. odf-kit's +// Standard style carries 0.212cm, which every paragraph and list item inherits — so the +// export must zero it, or LibreOffice shows ~6pt per block that the preview never had. +describe('exported Standard style', () => { + it('has no bottom margin, so the export matches the preview', async () => { + const json = { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'body' }] }, + { + type: 'bulletList', + content: [{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'item' }] }] }], + }, + ], + }; + const bytes = await buildOdt(json as never, DEFAULT_MARGINS, 'portrait'); + const styles = strFromU8(unzipSync(bytes)['styles.xml']); + + const standard = styles.match(//)![0]; + expect(standard).toContain('fo:margin-bottom="0cm"'); + expect(standard).not.toContain('0.212cm'); + }); +}); diff --git a/tests/unit/docx-default-style.test.ts b/tests/unit/docx-default-style.test.ts new file mode 100644 index 0000000..7c8b809 --- /dev/null +++ b/tests/unit/docx-default-style.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { DocxStyles } from '../../src/lib/import/docxStyles'; + +const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; + +// Word's "Normal" (here LibreOffice's German "Standard"): the w:default="1" paragraph +// style carries the real body font, while docDefaults still says Times New Roman. +const STYLES = ` + + + + + + + + + + + + + + + +`; + +const styles = () => new DocxStyles(new DOMParser().parseFromString(STYLES, 'application/xml'), null); + +describe('DocxStyles.paragraphRun', () => { + it('applies the default paragraph style to a paragraph with no w:pStyle', () => { + expect(styles().paragraphRun(null)).toMatchObject({ font: 'Arial', sizeHalfPt: 22 }); + }); + + it('resolves a pStyle through its basedOn chain to the default style', () => { + expect(styles().paragraphRun('Listenabsatz')).toMatchObject({ font: 'Arial', sizeHalfPt: 22 }); + }); + + it("lets a pStyle's own font win over the default style", () => { + expect(styles().paragraphRun('Quote').font).toBe('Georgia'); + }); + + it('falls back to docDefaults when there is no default paragraph style', () => { + const bare = ` + + + `; + const s = new DocxStyles(new DOMParser().parseFromString(bare, 'application/xml'), null); + expect(s.paragraphRun(null).font).toBe('Times New Roman'); + }); +}); + +describe('DocxStyles.paragraphSpacing', () => { + it('is empty when no layer sets spacing (Word implies 0)', () => { + expect(styles().paragraphSpacing(null)).toEqual({}); + }); + + it("reads modern Word's docDefaults spacing", () => { + const modern = ` + + + + + + + + + + `; + const s = new DocxStyles(new DOMParser().parseFromString(modern, 'application/xml'), null); + expect(s.paragraphSpacing(null)).toMatchObject({ after: 160, line: 259 }); + expect(s.paragraphSpacing('NoSpacing')).toMatchObject({ after: 0, line: 240 }); + }); +}); diff --git a/tests/unit/page-margins.test.ts b/tests/unit/page-margins.test.ts index 0c5a176..e2a3d4d 100644 --- a/tests/unit/page-margins.test.ts +++ b/tests/unit/page-margins.test.ts @@ -18,7 +18,7 @@ describe('pageMargins units', () => { expect(cmToPx(0)).toBe(0); }); - it('default margins are the documented A4 values', () => { - expect(DEFAULT_MARGINS).toEqual({ top: 2.54, bottom: 2.54, left: 2.12, right: 2.12 }); + it("default margins are LibreOffice's 2cm all round", () => { + expect(DEFAULT_MARGINS).toEqual({ top: 2, bottom: 2, left: 2, right: 2 }); }); });