fix: page breaks, empty lines, ...
This commit is contained in:
1 parent
40c139dd2c
commit
6535d4c58d
13 files changed
+413
-56
No files matched your search
@@ -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 += `<w:r>${rPr}${inner}</w:r>`;
|
||||
} else if (child.type === 'hardBreak') {
|
||||
runs += '<w:r><w:br/></w:r>';
|
||||
runs += `<w:r>${txbxRunPropsXml(child.marks)}<w:br/></w:r>`;
|
||||
}
|
||||
}
|
||||
return `<w:p>${pPr.length ? `<w:pPr>${pPr.join('')}</w:pPr>` : ''}${runs}</w:p>`;
|
||||
|
||||
+52
-11
@@ -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(`(<text:list text:style-name="L${i + 1}"[^>]*>\\s*<text:list-item)>`);
|
||||
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
|
||||
'<style:master-page style:name="Standard"',
|
||||
);
|
||||
|
||||
// List item paragraphs inherit Standard's fo:margin-bottom (0.212cm), but the editor
|
||||
// zeroes it (editor.css), so without this override every exported bullet gains ~6pt
|
||||
// vs. the preview. Explicit per-item spacing overrides this via LP styles.
|
||||
for (const name of ['List_20_Bullet', 'List_20_Number']) {
|
||||
styles = styles.replace(
|
||||
new RegExp(`(<style:style style:name="${name}"[^>]*?)/>`),
|
||||
`$1><style:paragraph-properties fo:margin-bottom="0cm"/></style:style>`,
|
||||
);
|
||||
}
|
||||
// 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(
|
||||
/(<style:style style:name="Standard"[\s\S]*?<style:paragraph-properties[^>]*?)fo:margin-bottom="[^"]*"/,
|
||||
`$1fo:margin-bottom="0cm"`,
|
||||
);
|
||||
|
||||
// Scope each rewrite to its own <style:style …>…</style:style> 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 (
|
||||
`<style:style style:name="Contents_20_Heading" style:family="paragraph" style:parent-style-name="Standard">` +
|
||||
`<style:paragraph-properties fo:margin-top="0cm" fo:margin-bottom="0.212cm"/>` +
|
||||
`<style:paragraph-properties fo:margin-top="0cm" fo:margin-bottom="0.3cm"/>` +
|
||||
`<style:text-properties fo:font-size="16pt" fo:font-weight="bold"/></style:style>`
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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). */
|
||||
|
||||
+64
-12
@@ -34,6 +34,7 @@ type Ctx = {
|
||||
imageCache: Map<string, string>;
|
||||
convertedImages: ConvertedImages;
|
||||
pendingBlocks: Node[];
|
||||
listCounters: Map<number, Map<number, number>>; // 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<string>();
|
||||
|
||||
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<string, unknown> {
|
||||
const attrs: Record<string, unknown> = {};
|
||||
|
||||
@@ -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<string, unknown> = {};
|
||||
@@ -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();
|
||||
|
||||
@@ -99,6 +99,7 @@ export class DocxStyles {
|
||||
private ownSpacing = new Map<string, ParaSpacing>(); // 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<string, string>();
|
||||
private abstractLevels = new Map<string, Map<number, LevelDef>>();
|
||||
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<string>()): 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 {
|
||||
|
||||
+13
-10
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <div class="tableWrapper"> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = `<?xml version="1.0"?><w:document ${W}><w:body>
|
||||
<w:p><w:r><w:t>Body text</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>Heading text</w:t></w:r></w:p>
|
||||
</w:body></w:document>`;
|
||||
const themeXml = `<?xml version="1.0"?><a:theme ${A}><a:themeElements><a:fontScheme>
|
||||
<a:majorFont><a:latin typeface="Calibri Light"/></a:majorFont>
|
||||
<a:minorFont><a:latin typeface="Calibri"/></a:minorFont>
|
||||
</a:fontScheme></a:themeElements></a:theme>`;
|
||||
|
||||
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" <br/> <br/> "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) => `<w:r><w:rPr><w:sz w:val="72"/></w:rPr>${inner}</w:r>`;
|
||||
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
|
||||
<w:p><w:pPr><w:rPr><w:sz w:val="72"/></w:rPr></w:pPr>
|
||||
${run('<w:t>Muster</w:t>')}${run('<w:br/>')}${run('<w:br/>')}${run('<w:t>Arbeitsvertrag</w:t>')}
|
||||
</w:p>
|
||||
</w:body></w:document>`;
|
||||
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) =>
|
||||
`<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="${numId}"/></w:numPr></w:pPr><w:r><w:t>${t}</w:t></w:r></w:p>`;
|
||||
const gap = (t: string) => `<w:p><w:r><w:t>${t}</w:t></w:r></w:p>`;
|
||||
// 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 = `<?xml version="1.0"?><w:document ${W}><w:body>
|
||||
${item(5, 'One')}${gap('body a')}${item(5, 'Two')}${gap('body b')}${item(5, 'Three')}${item(6, 'Other')}
|
||||
</w:body></w:document>`;
|
||||
const numberingXml = `<?xml version="1.0"?><w:numbering ${W}>
|
||||
<w:abstractNum w:abstractNumId="5"><w:lvl w:ilvl="0"><w:numFmt w:val="upperRoman"/><w:lvlText w:val="%1."/></w:lvl></w:abstractNum>
|
||||
<w:num w:numId="5"><w:abstractNumId w:val="5"/></w:num>
|
||||
<w:num w:numId="6"><w:abstractNumId w:val="5"/></w:num>
|
||||
</w:numbering>`;
|
||||
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 = '<w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/>';
|
||||
const build = (type: string | null) => {
|
||||
const t = type ? `<w:type w:val="${type}"/>` : '';
|
||||
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
|
||||
<w:p><w:r><w:t>section one</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:sectPr>${geom}</w:sectPr></w:pPr></w:p>
|
||||
<w:p><w:r><w:t>section two</w:t></w:r></w:p>
|
||||
<w:sectPr>${t}${geom}</w:sectPr>
|
||||
</w:body></w:document>`;
|
||||
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');
|
||||
});
|
||||
});
|
||||
+35
-2
@@ -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);
|
||||
|
||||
@@ -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(/<style:style style:name="Standard"[\s\S]*?<\/style:style>/)![0];
|
||||
expect(standard).toContain('fo:margin-bottom="0cm"');
|
||||
expect(standard).not.toContain('0.212cm');
|
||||
});
|
||||
});
|
||||
@@ -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 = `<?xml version="1.0"?>
|
||||
<w:styles xmlns:w="${W}">
|
||||
<w:docDefaults>
|
||||
<w:rPrDefault><w:rPr><w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman"/></w:rPr></w:rPrDefault>
|
||||
</w:docDefaults>
|
||||
<w:style w:type="paragraph" w:default="1" w:styleId="Standard">
|
||||
<w:name w:val="Normal"/>
|
||||
<w:rPr><w:rFonts w:ascii="Arial" w:hAnsi="Arial"/><w:sz w:val="22"/></w:rPr>
|
||||
</w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Listenabsatz">
|
||||
<w:name w:val="List Paragraph"/><w:basedOn w:val="Standard"/>
|
||||
</w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Quote">
|
||||
<w:name w:val="Quote"/>
|
||||
<w:rPr><w:rFonts w:ascii="Georgia" w:hAnsi="Georgia"/></w:rPr>
|
||||
</w:style>
|
||||
</w: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 = `<?xml version="1.0"?>
|
||||
<w:styles xmlns:w="${W}"><w:docDefaults><w:rPrDefault><w:rPr>
|
||||
<w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman"/>
|
||||
</w:rPr></w:rPrDefault></w:docDefaults></w:styles>`;
|
||||
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 = `<?xml version="1.0"?>
|
||||
<w:styles xmlns:w="${W}">
|
||||
<w:docDefaults><w:pPrDefault><w:pPr>
|
||||
<w:spacing w:after="160" w:line="259" w:lineRule="auto"/>
|
||||
</w:pPr></w:pPrDefault></w:docDefaults>
|
||||
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="NoSpacing">
|
||||
<w:name w:val="No Spacing"/><w:basedOn w:val="Normal"/>
|
||||
<w:pPr><w:spacing w:after="0" w:line="240" w:lineRule="auto"/></w:pPr>
|
||||
</w:style>
|
||||
</w:styles>`;
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user