diff --git a/src/lib/components/ParagraphBorderPicker.svelte b/src/lib/components/ParagraphBorderPicker.svelte new file mode 100644 index 0000000..bfdd7b6 --- /dev/null +++ b/src/lib/components/ParagraphBorderPicker.svelte @@ -0,0 +1,300 @@ + + +
+ + + {#if open} +
+
{t().toolbarExpanded.paragraphBorders}
+
+ {#each PRESETS as preset} + + {/each} +
+ +
{t().borders.lineWidth}
+
+ {#each WIDTHS as w} + + {/each} +
+ +
{t().borders.lineColor}
+
+ {#each COLORS as row} +
+ {#each row as c} + + {/each} +
+ {/each} +
+
+ {/if} +
+ + diff --git a/src/lib/components/ToolbarExpanded.svelte b/src/lib/components/ToolbarExpanded.svelte index 59ff507..0fe9d90 100644 --- a/src/lib/components/ToolbarExpanded.svelte +++ b/src/lib/components/ToolbarExpanded.svelte @@ -2,6 +2,7 @@ import type { Editor } from '@tiptap/core'; import { onMount } from 'svelte'; import ColorPicker from './ColorPicker.svelte'; + import ParagraphBorderPicker from './ParagraphBorderPicker.svelte'; import TablePicker from './TablePicker.svelte'; import SpecialCharPicker from './SpecialCharPicker.svelte'; import DateTimePicker from './DateTimePicker.svelte'; @@ -271,6 +272,23 @@ let currentFontColor = $derived(uniformMarkColor('textStyle')); let currentHighlightColor = $derived(uniformMarkColor('highlight')); + // Uniform paragraph background across the selection (paragraphBox.ts). null = none + // anywhere; '' = mixed. Feeds the paragraph-shading ColorPicker. + let currentParaBackground = $derived.by(() => { + if (tick < 0 || !editor) return null; + const { from, to, empty } = editor.state.selection; + if (empty) return (editor.state.selection.$head.parent.attrs.backgroundColor ?? null) as string | null; + let c: string | null | undefined; + let mixed = false; + editor.state.doc.nodesBetween(from, to, (node) => { + if (mixed || !('backgroundColor' in node.attrs)) return; + const b = (node.attrs.backgroundColor ?? null) as string | null; + if (c === undefined) c = b; + else if (c !== b) mixed = true; + }); + return mixed ? '' : (c ?? null); + }); + // Sub/superscript are mutually exclusive (Word-style): toggling one clears the // other. odf-kit maps these marks to text:position (round-trips with LibreOffice // and Word). See export/odt.ts applyRuns for the custom-attr-paragraph path. @@ -289,6 +307,7 @@ let lineHeightOpen = $state(false); let fontColorOpen = $state(false); let highlightColorOpen = $state(false); + let paraShadeOpen = $state(false); let sizeInputFocused = $state(false); let sizeInputValue = $state(''); let savedFrom: number | null = null; @@ -307,6 +326,7 @@ lineHeightOpen = false; fontColorOpen = false; highlightColorOpen = false; + paraShadeOpen = false; // Save selection before the picker button steals focus. savedFrom = editor.state.selection.from; savedTo = editor.state.selection.to; @@ -354,6 +374,7 @@ lineHeightOpen = false; fontColorOpen = false; highlightColorOpen = false; + paraShadeOpen = false; savedFrom = editor.state.selection.from; savedTo = editor.state.selection.to; (e.target as HTMLInputElement).select(); @@ -382,6 +403,7 @@ lineHeightOpen = false; fontColorOpen = false; highlightColorOpen = false; + paraShadeOpen = false; if (!sizeInputFocused) { savedFrom = editor.state.selection.from; savedTo = editor.state.selection.to; @@ -413,6 +435,7 @@ sizeInputFocused = false; fontColorOpen = false; highlightColorOpen = false; + paraShadeOpen = false; lineHeightOpen = !lineHeightOpen; } @@ -451,6 +474,7 @@ sizeInputFocused = false; fontColorOpen = false; highlightColorOpen = false; + paraShadeOpen = false; spaceBeforeOpen = false; spaceAfterOpen = false; if (axis === 'before') spaceBeforeFocused = true; else spaceAfterFocused = true; @@ -623,6 +647,7 @@ lineHeightOpen = false; fontColorOpen = false; highlightColorOpen = false; + paraShadeOpen = false; columnsOpen = false; layoutOpen = !layoutOpen; if (layoutOpen) { @@ -639,17 +664,18 @@ return { destroy() { window.removeEventListener('mousedown', handler); } }; } - // The color pickers (font + highlight) live in ColorPicker.svelte. When one - // opens it asks the parent (via the ColorPicker `onOpen` prop) to close the - // sibling toolbar dropdowns and the other color picker. - function onColorPickerOpen(which: 'font' | 'highlight') { + // The color pickers (font + highlight + paragraph shading) live in ColorPicker.svelte. + // When one opens it asks the parent (via the `onOpen` prop) to close the sibling + // toolbar dropdowns and the other color pickers. + function onColorPickerOpen(which: 'font' | 'highlight' | 'paraShade') { fontOpen = false; sizeOpen = false; sizeInputFocused = false; lineHeightOpen = false; columnsOpen = false; - if (which === 'font') highlightColorOpen = false; - else fontColorOpen = false; + if (which !== 'font') fontColorOpen = false; + if (which !== 'highlight') highlightColorOpen = false; + if (which !== 'paraShade') paraShadeOpen = false; } // --- Table insertion (TablePicker.svelte) --- @@ -663,6 +689,7 @@ lineHeightOpen = false; fontColorOpen = false; highlightColorOpen = false; + paraShadeOpen = false; layoutOpen = false; specialCharOpen = false; columnsOpen = false; @@ -699,6 +726,7 @@ lineHeightOpen = false; fontColorOpen = false; highlightColorOpen = false; + paraShadeOpen = false; layoutOpen = false; tableOpen = false; specialCharOpen = false; @@ -770,6 +798,7 @@ lineHeightOpen = false; fontColorOpen = false; highlightColorOpen = false; + paraShadeOpen = false; layoutOpen = false; tableOpen = false; columnsOpen = false; @@ -793,6 +822,7 @@ lineHeightOpen = false; fontColorOpen = false; highlightColorOpen = false; + paraShadeOpen = false; layoutOpen = false; tableOpen = false; columnsOpen = false; @@ -1034,6 +1064,26 @@ {/snippet} + onColorPickerOpen('paraShade')} + onApply={(c, r) => editor?.chain().focus().setTextSelection(r).setParagraphBackground(c).run()} + onClear={(r) => editor?.chain().focus().setTextSelection(r).setParagraphBackground(null).run()} + > + {#snippet icon()} + + {/snippet} + +
diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index b1941ec..034cb09 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -25,6 +25,7 @@ import TextAlign from '@tiptap/extension-text-align'; import { PageBreaks } from './extensions/pageBreaks'; import { LineHeight } from './extensions/lineHeight'; import { ParagraphSpacing } from './extensions/paragraphSpacing'; +import { ParagraphBox } from './extensions/paragraphBox'; import { BlockFontSize } from './extensions/blockFontSize'; import { PageBreak } from './extensions/pageBreak'; import { Indent } from './extensions/indent'; @@ -90,6 +91,9 @@ export const extensions = [ TableOfContents, LineHeight, ParagraphSpacing, + // Paragraph background ("colored field") + borders ("colored rule line"); round-trips + // to ODF fo:background-color/fo:border-* and DOCX w:shd/w:pBdr. + ParagraphBox, // Paragraph-mark font size: sizes empty lines (and text typed into them). BlockFontSize, PageBreak, diff --git a/src/lib/editor/extensions/headerFooter.ts b/src/lib/editor/extensions/headerFooter.ts index 7506f68..888b57a 100644 --- a/src/lib/editor/extensions/headerFooter.ts +++ b/src/lib/editor/extensions/headerFooter.ts @@ -15,6 +15,7 @@ import Placeholder from '@tiptap/extension-placeholder'; import TextAlign from '@tiptap/extension-text-align'; import { FontWeight } from './fontWeight'; import { FontColor } from './fontColor'; +import { ParagraphBox } from './paragraphBox'; import { PageNumber, PageCount } from './pageField'; import { Image } from './image'; @@ -52,6 +53,8 @@ export function hfExtensions(placeholder = '') { FontSize, FontWeight, FontColor, + // Paragraph background ("colored field") + rule line, common in letterhead headers. + ParagraphBox, Highlight.configure({ multicolor: true }), HfHardBreak, // Inline (as-character) images only — the single-paragraph zone has no text flow diff --git a/src/lib/editor/extensions/paragraphBox.ts b/src/lib/editor/extensions/paragraphBox.ts new file mode 100644 index 0000000..ea7d0f3 --- /dev/null +++ b/src/lib/editor/extensions/paragraphBox.ts @@ -0,0 +1,168 @@ +import { Extension } from '@tiptap/core'; +import type { CommandProps } from '@tiptap/core'; +import type { EditorState } from '@tiptap/pm/state'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { cssBorder, parseBorderAttr, type BorderSpec } from './tableCellBorders'; + +// Paragraph/heading background ("colored field") + borders ("rule line"), as in +// Word/LibreOffice letterheads. Border attrs share table cells' canonical +// `'pt solid #RRGGBB'`, but here null/absent = no border (no 'none' sentinel). + +export type ParaBorderPreset = 'all' | 'top' | 'bottom' | 'left' | 'right'; +export const PARA_BORDER_PRESETS: ParaBorderPreset[] = ['all', 'top', 'bottom', 'left', 'right']; + +const BORDER_SIDES = ['borderTop', 'borderRight', 'borderBottom', 'borderLeft'] as const; +type Side = (typeof BORDER_SIDES)[number]; + +const SIDE_META: Record = { + borderTop: { data: 'data-pborder-top', css: 'border-top' }, + borderRight: { data: 'data-pborder-right', css: 'border-right' }, + borderBottom: { data: 'data-pborder-bottom', css: 'border-bottom' }, + borderLeft: { data: 'data-pborder-left', css: 'border-left' }, +}; + +const PRESET_SIDES: Record = { + all: ['borderTop', 'borderRight', 'borderBottom', 'borderLeft'], + top: ['borderTop'], + bottom: ['borderBottom'], + left: ['borderLeft'], + right: ['borderRight'], +}; + +// Canonical attr value for a paragraph border; null spec = remove the border. Unlike the +// table version, the 0.5pt-black default is NOT collapsed (a chosen paragraph rule renders). +export function paraBorderValue(spec: BorderSpec): string | null { + if (!spec) return null; + const w = Math.round(spec.widthPt * 100) / 100; + return `${w}pt solid ${spec.color.toUpperCase()}`; +} + +declare module '@tiptap/core' { + interface Commands { + paragraphBox: { + setParagraphBackground: (color: string | null) => ReturnType; + setParagraphBorders: (preset: ParaBorderPreset, spec: BorderSpec) => ReturnType; + }; + } +} + +// The paragraph/heading textblocks the selection covers (the caret's own block when empty). +function selectedBlocks(state: EditorState): { pos: number; node: PMNode }[] { + const out: { pos: number; node: PMNode }[] = []; + const { from, to } = state.selection; + state.doc.nodesBetween(from, to, (node, pos) => { + if (node.type.name === 'paragraph' || node.type.name === 'heading') out.push({ pos, node }); + }); + if (out.length === 0) { + const { $from } = state.selection; + const parent = $from.parent; + if (parent.type.name === 'paragraph' || parent.type.name === 'heading') { + out.push({ pos: $from.before(), node: parent }); + } + } + return out; +} + +export const ParagraphBox = Extension.create({ + name: 'paragraphBox', + + addOptions() { + return { types: ['paragraph', 'heading'] as string[] }; + }, + + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + backgroundColor: { + default: null, + parseHTML: (element: HTMLElement) => element.getAttribute('data-bg') || null, + renderHTML: (attributes: Record) => { + const c = attributes.backgroundColor as string | null; + if (!c) return {}; + return { 'data-bg': c, style: `background-color: ${c}` }; + }, + }, + ...Object.fromEntries( + BORDER_SIDES.map((side) => [ + side, + { + default: null, + parseHTML: (element: HTMLElement) => element.getAttribute(SIDE_META[side].data), + renderHTML: (attributes: Record) => { + const v = attributes[side] as string | null; + if (!v) return {}; + const css = cssBorder(v); + if (!css || css === 'none') return {}; + return { [SIDE_META[side].data]: v, style: `${SIDE_META[side].css}: ${css}` }; + }, + }, + ]), + ), + }, + }, + ]; + }, + + addCommands() { + return { + setParagraphBackground: + (color: string | null) => + ({ state, tr, dispatch }: CommandProps): boolean => { + const blocks = selectedBlocks(state); + if (!blocks.length) return false; + if (dispatch) { + for (const { pos, node } of blocks) { + tr.setNodeMarkup(pos, undefined, { ...node.attrs, backgroundColor: color }); + } + dispatch(tr); + } + return true; + }, + setParagraphBorders: + (preset: ParaBorderPreset, spec: BorderSpec) => + ({ state, tr, dispatch }: CommandProps): boolean => { + const blocks = selectedBlocks(state); + if (!blocks.length) return false; + const value = paraBorderValue(spec); + const patch: Partial> = {}; + for (const side of PRESET_SIDES[preset]) patch[side] = value; + if (dispatch) { + for (const { pos, node } of blocks) { + tr.setNodeMarkup(pos, undefined, { ...node.attrs, ...patch }); + } + dispatch(tr); + } + return true; + }, + }; + }, +}); + +// Word-like active states for the paragraph border picker: a preset is active when every +// covered block renders exactly the pen border on every side the preset targets. `none` +// is active when every covered block is borderless. null when no block is selected. +export function activeParagraphBorderPresets( + state: EditorState, + pen: Exclude, +): Record | null { + const blocks = selectedBlocks(state); + if (!blocks.length) return null; + + const matchesPen = (attrVal: unknown) => { + const b = parseBorderAttr(attrVal as string | null); + return b !== null && b !== 'none' && Math.abs(b.widthPt - pen.widthPt) < 0.01 && b.color === pen.color.toUpperCase(); + }; + const sideEmpty = (attrVal: unknown) => { + const b = parseBorderAttr(attrVal as string | null); + return b === null || b === 'none'; + }; + + const out = {} as Record; + for (const preset of PARA_BORDER_PRESETS) { + out[preset] = blocks.every(({ node }) => PRESET_SIDES[preset].every((s) => matchesPen(node.attrs[s]))); + } + out.none = blocks.every(({ node }) => BORDER_SIDES.every((s) => sideEmpty(node.attrs[s]))); + return out; +} diff --git a/src/lib/editor/extensions/tableCellBorders.ts b/src/lib/editor/extensions/tableCellBorders.ts index 8e577a9..05af254 100644 --- a/src/lib/editor/extensions/tableCellBorders.ts +++ b/src/lib/editor/extensions/tableCellBorders.ts @@ -53,7 +53,7 @@ export function parseBorderAttr( // CSS value for a side. The default 0.5pt renders as 1px (editor.css); custom widths // scale the same way (pt → px, min 1px so thin lines stay crisp). -function cssBorder(value: string): string | null { +export function cssBorder(value: string): string | null { const b = parseBorderAttr(value); if (b === null) return null; if (b === 'none') return 'none'; diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 45932ef..fa40add 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -579,6 +579,21 @@ const HEADING_LEVEL: Record = {}; @@ -596,6 +611,8 @@ function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph { indent: indent.left != null ? indent : undefined, pageBreakBefore: attrs.breakBefore === 'page' || undefined, numbering: opts.numbering, + shading: paraShadingOf(attrs), + border: paraBordersOf(attrs), run: markSize ? { size: markSize } : undefined, children: inlineToRuns(node.content, opts.forceBold), }); diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 608e70f..4f43628 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -74,6 +74,11 @@ const PGB = ''; // style fo:font-size (odf-kit has no paragraph font-size option). U+E007. const FSZ = '\uE007'; +// Sentinel wrapping a top-level paragraph's box spec (PBXPBX), emitted +// as the paragraph's first run; applyParagraphBoxes mints a paragraph style with +// fo:background-color/fo:border-* (odf-kit has no such options). U+E00C. +const PBX = '\uE00C'; + // Sentinel wrapping a table-of-contents index (TOC{i}TOC), emitted as a marker // paragraph's run text by replaceTableOfContents so it rides odf-kit's paragraph path; // applyToc rewrites the whole marker to a . U+E006. @@ -132,6 +137,9 @@ function hasCustomAttrs(attrs: TiptapNode['attrs']): boolean { if (attrs.spaceAfter != null) return true; if (typeof attrs.fontSize === 'string' && attrs.fontSize) return true; if (typeof attrs.indent === 'number' && attrs.indent > 0) return true; + if (typeof attrs.backgroundColor === 'string' && attrs.backgroundColor) return true; + for (const s of ['borderTop', 'borderRight', 'borderBottom', 'borderLeft']) + if (typeof attrs[s] === 'string' && attrs[s] && attrs[s] !== 'none') return true; const ta = attrs.textAlign; return ta === 'left' || ta === 'center' || ta === 'right' || ta === 'justify'; } @@ -486,10 +494,19 @@ type ParaStyle = { spaceBefore: number | null; spaceAfter: number | null; lineHeight: number | string | null; + // Paragraph background ("colored field") + per-side border ("rule line"). The border + // attr value ('pt solid #RRGGBB') is already a valid ODF fo:border value. + background: string | null; + borderTop: string | null; + borderRight: string | null; + borderBottom: string | null; + borderLeft: string | null; }; function paraStyleIsEmpty(s: ParaStyle): boolean { - return s.align === null && s.spaceBefore === null && s.spaceAfter === null && s.lineHeight === null; + return s.align === null && s.spaceBefore === null && s.spaceAfter === null && s.lineHeight === null + && s.background === null && s.borderTop === null && s.borderRight === null + && s.borderBottom === null && s.borderLeft === null; } // Extract the overridable paragraph properties from a node's attrs. Left @@ -504,11 +521,17 @@ function paraStyleFromAttrs(attrs: TiptapNode['attrs']): ParaStyle { const lhNum = parseFloat(String(lh)); lineHeight = isNaN(lhNum) ? String(lh) : lhNum; } + const border = (v: unknown) => (typeof v === 'string' && v && v !== 'none' ? v : null); return { align: ta === 'center' || ta === 'right' || ta === 'justify' ? ta : null, spaceBefore: typeof sb === 'number' ? sb : null, spaceAfter: typeof sa === 'number' ? sa : null, lineHeight, + background: typeof attrs?.backgroundColor === 'string' && attrs.backgroundColor ? attrs.backgroundColor : null, + borderTop: border(attrs?.borderTop), + borderRight: border(attrs?.borderRight), + borderBottom: border(attrs?.borderBottom), + borderLeft: border(attrs?.borderLeft), }; } @@ -519,6 +542,14 @@ function paraStyleProps(style: ParaStyle): string[] { if (style.spaceBefore != null) props.push(`fo:margin-top="${style.spaceBefore}pt"`); if (style.spaceAfter != null) props.push(`fo:margin-bottom="${style.spaceAfter}pt"`); if (style.lineHeight != null) props.push(`fo:line-height="${normalizeLineHeight(style.lineHeight)}"`); + if (style.background) props.push(`fo:background-color="${style.background}"`); + // The canonical border attr ('pt solid #RRGGBB') is itself a valid fo:border value. + for (const [attr, side] of [ + ['borderTop', 'top'], ['borderRight', 'right'], ['borderBottom', 'bottom'], ['borderLeft', 'left'], + ] as const) { + const v = style[attr]; + if (v) props.push(`fo:border-${side}="${v}"`); + } return props; } @@ -820,6 +851,97 @@ function applyEmptyLineFontSizes(odtBytes: Uint8Array): Uint8Array { return rezipOdt(files); } +// A top-level paragraph's box spec for the PBX sentinel: bg|borderTop|Right|Bottom|Left, +// each the raw value (canonical border 'pt solid #RRGGBB' is a valid fo:border) or ''. +// '' overall when the paragraph has no background/border. +function paraBoxSpec(attrs: TiptapNode['attrs']): string { + const s = paraStyleFromAttrs(attrs); + if (!s.background && !s.borderTop && !s.borderRight && !s.borderBottom && !s.borderLeft) return ''; + return [s.background, s.borderTop, s.borderRight, s.borderBottom, s.borderLeft].map((v) => v ?? '').join('|'); +} + +function boxSpecToProps(spec: string): string { + const [bg, bt, br, bb, bl] = spec.split('|'); + const props: string[] = []; + if (bg) props.push(`fo:background-color="${bg}"`); + if (bt) props.push(`fo:border-top="${bt}"`); + if (br) props.push(`fo:border-right="${br}"`); + if (bb) props.push(`fo:border-bottom="${bb}"`); + if (bl) props.push(`fo:border-left="${bl}"`); + return props.join(' '); +} + +// Add fo:* into a cloned style's (which precedes text-props +// in ODF). Mirrors cloneStyleWithFontSize but for paragraph-properties. +function cloneStyleWithParaProps(def: string, newName: string, props: string): string { + const s = def.replace(/style:name="[^"]*"/, `style:name="${newName}"`); + if (/]*\/>/.test(s)) + return s.replace(/]*?)\s*\/>/, ``); + if (/]*)>/, ``); + if (/^]*\/>\s*$/.test(s)) + return s.replace(/\s*\/>\s*$/, `>`); + if (/', ``); +} + +// Resolve PBX sentinels: per (source style, box spec) mint a paragraph style cloning the +// source plus fo:background-color/fo:border-*, reassign the block, and strip the sentinel. +// Mirrors applyEmptyLineFontSizes; runs after it so an empty line's FSZ is already resolved. +function applyParagraphBoxes(odtBytes: Uint8Array): Uint8Array { + const files = unzipSync(odtBytes); + const contentBytes = files['content.xml']; + if (!contentBytes) return odtBytes; + + let content = strFromU8(contentBytes); + if (!content.includes(PBX)) return odtBytes; + + const minted: string[] = []; + const nameByKey = new Map(); + let counter = 0; + + const boxStyleFor = (source: string, spec: string): string => { + const key = `${source}|${spec}`; + const existing = nameByKey.get(key); + if (existing) return existing; + const name = `PB${++counter}`; + nameByKey.set(key, name); + const props = boxSpecToProps(spec); + const def = source ? findAutoStyle(content, source) : null; + minted.push(def + ? cloneStyleWithParaProps(def, name, props) + : ``, + ); + return name; + }; + + const pbxRe = new RegExp(`${PBX}([^${PBX}]*)${PBX}`); + content = content.replace( + new RegExp(`]*)>([\\s\\S]*?)`, 'g'), + (m, tag: string, attrs: string, inner: string) => { + const sm = pbxRe.exec(inner); + if (!sm) return m; + const cleaned = inner.replace(pbxRe, ''); + const srcM = /text:style-name="([^"]*)"/.exec(attrs); + const name = boxStyleFor(srcM ? srcM[1] : '', sm[1]); + const newAttrs = srcM + ? attrs.replace(/text:style-name="[^"]*"/, `text:style-name="${name}"`) + : ` text:style-name="${name}"${attrs}`; + return `${cleaned}`; + }, + ); + // Drop any sentinels not consumed above (defensive — never legitimate text). + content = content.replace(new RegExp(`${PBX}[^${PBX}]*${PBX}`, 'g'), ''); + + if (minted.length) { + content = content.replace('', `${minted.join('')}`); + } + + files['content.xml'] = strToU8(content); + return rezipOdt(files); +} + // odf-kit always emits ordered lists as "1." at every level. This rewrites the // matching L# list style per level: the depth-default cycle (1. → a. → i.), explicit // listStyleType attrs, or the multilevel chain (decimal + text:display-levels). @@ -2201,17 +2323,20 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU // that applyEmptyLineFontSizes turns into a paragraph-style fo:font-size. const emptyFs = content.length === 0 && typeof node.attrs?.fontSize === 'string' && node.attrs.fontSize ? `${FSZ}${node.attrs.fontSize}${FSZ}` : ''; + // Paragraph background/borders ride as a leading PBX sentinel (odf-kit has no such + // options); applyParagraphBoxes mints the style. FSZ stays first so its own pass, + // which runs earlier, still matches it right after the opening tag. + const spec = paraBoxSpec(node.attrs); + const pbx = spec ? `${PBX}${spec}${PBX}` : ''; + const withPbx = (p: ParagraphBuilder) => { if (pbx) p.addText(pbx); applyRuns(p, content); }; if (node.type === CUST_P) { - if (content.length === 0) { - doc.addParagraph(emptyFs, opts); - } else { - doc.addParagraph((p: ParagraphBuilder) => applyRuns(p, content), opts); - } + if (content.length === 0) doc.addParagraph(emptyFs + pbx, opts); + else doc.addParagraph(withPbx, opts); } else if (node.type === CUST_H) { const level = (node.attrs?.level as number) ?? 1; - if (content.length === 0) doc.addHeading(emptyFs, level, opts); - else doc.addHeading((p: ParagraphBuilder) => applyRuns(p, content), level, opts); + if (content.length === 0) doc.addHeading(emptyFs + pbx, level, opts); + else doc.addHeading(withPbx, level, opts); } }, }); @@ -2264,7 +2389,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU const withToc = applyToc(withColumns, tocs, contentWidthCm); const withPageBreaks = applyPageBreaks(withToc); const withEmptyFontSizes = applyEmptyLineFontSizes(withPageBreaks); - const withStyles = rewriteStylesXml(withEmptyFontSizes, language ?? null, pageFormat, orientation); + const withParaBoxes = applyParagraphBoxes(withEmptyFontSizes); + const withStyles = rewriteStylesXml(withParaBoxes, language ?? null, pageFormat, orientation); return applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist, firstHeaderPara, firstFooterPara, hf?.pageCount ?? 1, hfImages, evenHeaderPara, evenFooterPara); } @@ -2273,6 +2399,22 @@ function hfAlign(para: TiptapNode): AlignValue | null { return ta === 'center' || ta === 'right' || ta === 'justify' ? ta : null; } +// fo:* paragraph-properties for a header/footer paragraph: alignment plus the paragraph +// background ("colored field") and per-side borders ("rule line"). Empty ⇒ no override. +function hfParaProps(para: TiptapNode): string[] { + const props: string[] = []; + const align = hfAlign(para); + if (align) props.push(`fo:text-align="${align}"`); + const s = paraStyleFromAttrs(para.attrs); + if (s.background) props.push(`fo:background-color="${s.background}"`); + for (const [attr, side] of [ + ['borderTop', 'top'], ['borderRight', 'right'], ['borderBottom', 'bottom'], ['borderLeft', 'left'], + ] as const) { + if (s[attr]) props.push(`fo:border-${side}="${s[attr]}"`); + } + return props; +} + // Header/footer post-processing on styles.xml: resolve LBR/PGC sentinels, apply the // paragraph alignment to the Header/Footer styles, and rewrite the geometry to the // Word-style mapping (page margin = HF distance, min-height fills up to the body margin). @@ -2301,12 +2443,12 @@ function applyHfPostProcess(odtBytes: Uint8Array, margins: PageMargins, headerPa new RegExp(`[\\s\\S]*?`), ``, ); - const align = hfAlign(para); - if (align) { + const props = hfParaProps(para); + if (props.length) { const styleName = kind === 'header' ? 'Header' : 'Footer'; styles = styles.replace( new RegExp(`(]*?)/>`), - `$1>`, + `$1>`, ); } }; @@ -2399,11 +2541,11 @@ function hfVariantZoneXml(kind: 'header' | 'footer', suffix: 'first' | 'left', p } const parent = kind === 'header' ? 'Header' : 'Footer'; - const align = hfAlign(para); + const props = hfParaProps(para); let paraStyle = parent; - if (align) { + if (props.length) { paraStyle = `${pfx}P`; - mint(``); + mint(``); } return `${inner}`; } diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index ee0452c..0759e23 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -80,6 +80,9 @@ const de: Messages = { highlightColor: 'Hervorhebungsfarbe', chooseHighlightColor: 'Hervorhebungsfarbe wählen', noColor: 'Keine Farbe', + paragraphShading: 'Absatzhintergrund', + chooseParagraphShading: 'Absatzhintergrund wählen', + paragraphBorders: 'Absatzränder', superscript: 'Hochgestellt', subscript: 'Tiefgestellt', decreaseIndent: 'Einzug verkleinern', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index a629cba..f9fd8f1 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -83,6 +83,9 @@ const en = { highlightColor: 'Highlight color', chooseHighlightColor: 'Choose highlight color', noColor: 'No color', + paragraphShading: 'Paragraph shading', + chooseParagraphShading: 'Choose paragraph shading', + paragraphBorders: 'Paragraph borders', superscript: 'Superscript', subscript: 'Subscript', decreaseIndent: 'Decrease indent', diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 1239380..667d57d 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -582,9 +582,40 @@ function blockAttrs(ppr: Element | null, kind: BlockKind, headingLevel: number | const pb = fc(ppr, 'pageBreakBefore'); if (pb && wVal(pb) !== 'false' && wVal(pb) !== '0') attrs.breakBefore = 'page'; } + + // Paragraph background ("colored field", w:shd) + per-side borders ("rule line", w:pBdr). + Object.assign(attrs, readParaBox(ppr)); return attrs; } +// Paragraph background + per-side border attrs (paragraphBox.ts) from direct w:pPr. +// Shading: w:shd/@w:fill; borders: w:pBdr/w:{top,right,bottom,left} (w:sz in eighths of a +// point). Sides with w:val none/nil are skipped. Matches the ODF paraBoxAttrs shape. +function readParaBox(ppr: Element | null): Record { + const out: Record = {}; + if (!ppr) return out; + const shd = fc(ppr, 'shd'); + const fill = shd ? hexColor(shd.getAttributeNS(W, 'fill')) : undefined; + if (fill) out.backgroundColor = fill; + + const pBdr = fc(ppr, 'pBdr'); + if (pBdr) { + for (const [wSide, attr] of [ + ['top', 'borderTop'], ['right', 'borderRight'], ['bottom', 'borderBottom'], ['left', 'borderLeft'], + ] as const) { + const b = fc(pBdr, wSide); + if (!b) continue; + const val = b.getAttributeNS(W, 'val'); + if (!val || val === 'none' || val === 'nil') continue; + const sz = intAttr(b, W, 'sz'); // eighths of a point + const widthPt = sz != null ? Math.round((sz / 8) * 100) / 100 : 0.5; + const color = hexColor(b.getAttributeNS(W, 'color')) ?? '#000000'; + out[attr] = `${widthPt}pt solid ${color}`; + } + } + return out; +} + function snapPt(v: number): number { const r = Math.round(v * 100) / 100; const i = Math.round(r); @@ -634,11 +665,13 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, headingLevel: nu // Route a drawing/pict result: an image is inline; a text box is a block node // riding ctx.pendingBlocks. The one-paragraph header/footer schema holds inline - // images (forced as-character) but not boxes — those are dropped with a warning. - const pushDrawn = (n: Node | null) => { + // (as-character) images but not boxes — those are dropped with a warning. A floating + // drawing (page background, watermark, fold marks) can't be placed in the one-paragraph + // zone and, sized to the page, would overlay the whole document, so it's dropped there. + const pushDrawn = (n: Node | null, floating: boolean) => { if (!n) return; if (hfFields) { - if (n.type === 'image') { out.push({ ...n, attrs: { ...n.attrs, wrap: 'inline' } }); return; } + if (n.type === 'image' && !floating) { out.push({ ...n, attrs: { ...n.attrs, wrap: 'inline' } }); return; } ctx.warnings.add('Drawings were removed'); return; } @@ -652,12 +685,12 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, headingLevel: nu if (child.namespaceURI === MC && child.localName === 'AlternateContent') { const choice = Array.from(child.children).find((c) => c.namespaceURI === MC && c.localName === 'Choice'); const drawing = choice?.getElementsByTagNameNS(W, 'drawing')[0]; - if (drawing) pushDrawn(convertDrawing(drawing, ctx)); + if (drawing) pushDrawn(convertDrawing(drawing, ctx), drawingIsFloating(drawing)); else { const pict = Array.from(child.children) .find((c) => c.namespaceURI === MC && c.localName === 'Fallback') ?.getElementsByTagNameNS(W, 'pict')[0]; - if (pict) pushDrawn(convertPict(pict, ctx)); + if (pict) pushDrawn(convertPict(pict, ctx), drawingIsFloating(pict)); else ctx.warnings.add('Drawings were removed'); } continue; @@ -687,8 +720,8 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, headingLevel: nu case 'tab': if (!skipResult()) pushText('\t', marks); 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; + case 'drawing': pushDrawn(convertDrawing(child, ctx), drawingIsFloating(child)); break; + case 'pict': pushDrawn(convertPict(child, ctx), drawingIsFloating(child)); break; } } }; @@ -817,6 +850,16 @@ function loadImageDataUrl(path: string, ctx: Ctx): string | null { return url; } +// True when a drawing floats rather than sitting in the text flow: DrawingML `wp:anchor`, +// or a VML shape with `position:absolute`. Header/footer import drops these (see pushDrawn). +function drawingIsFloating(el: Element): boolean { + if (el.localName === 'drawing') return el.getElementsByTagNameNS(WP, 'anchor').length > 0; + const shape = Array.from(el.getElementsByTagNameNS(VML, '*')).find((c) => + ['shape', 'rect', 'oval', 'roundrect'].includes(c.localName), + ); + return /(^|;)\s*position\s*:\s*absolute/i.test(shape?.getAttribute('style') ?? ''); +} + function convertDrawing(drawing: Element, ctx: Ctx): Node | null { const anchor = drawing.getElementsByTagNameNS(WP, 'anchor')[0]; const root = drawing.getElementsByTagNameNS(WP, 'inline')[0] ?? anchor; @@ -1225,6 +1268,7 @@ function convertHfPart(relId: string | null, ctx: Ctx): HfDoc { const inline: Node[] = []; let textAlign: string | null = null; + const boxMaps: Record[] = []; for (const p of fcAll(root, 'p')) { if (inline.length) inline.push({ type: 'hardBreak' }); const ppr = fc(p, 'pPr'); @@ -1232,14 +1276,36 @@ 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' : ''; } + boxMaps.push(readParaBox(ppr)); 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)); } - // Empty source paragraphs become blank lines (hardBreaks); an all-empty zone has no - // runs and no inter-paragraph break, so inline stays empty and the zone is dropped. - if (inline.length === 0) return null; + // An all-empty zone is dropped unless it carries a background/rule line (a footer that + // is just a colored line has no text). The zone collapses to one paragraph (mergeHfBox). + const box = mergeHfBox(boxMaps); + if (inline.length === 0 && Object.keys(box).length === 0) return null; const para: Node = { type: 'paragraph', content: inline }; - if (textAlign) para.attrs = { textAlign }; + const attrs: Record = {}; + if (textAlign) attrs.textAlign = textAlign; + Object.assign(attrs, box); + if (Object.keys(attrs).length) para.attrs = attrs; return { type: 'doc', content: [para] }; } + +// Collapse several source paragraphs' box props into one (mirror of odt.ts mergeHfBox). +function mergeHfBox(maps: Record[]): Record { + const out: Record = {}; + const first = (k: string) => maps.find((m) => m[k] !== undefined)?.[k]; + const last = (k: string) => [...maps].reverse().find((m) => m[k] !== undefined)?.[k]; + for (const [k, v] of [ + ['backgroundColor', first('backgroundColor')], + ['borderTop', first('borderTop')], + ['borderLeft', first('borderLeft')], + ['borderRight', first('borderRight')], + ['borderBottom', last('borderBottom')], + ] as const) { + if (v !== undefined) out[k] = v; + } + return out; +} diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index bb3d3e6..8c375bb 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -408,6 +408,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = function convertHfZone(zoneEl: Element, ctx: Ctx): HfDoc { const inline: Node[] = []; let textAlign: string | null = null; + const boxMaps: Record[] = []; const addPara = (p: Element) => { if (inline.length) inline.push({ type: 'hardBreak' }); @@ -417,6 +418,7 @@ function convertHfZone(zoneEl: Element, ctx: Ctx): HfDoc { textAlign = ta === 'center' || ta === 'justify' ? ta : ta === 'right' || ta === 'end' ? 'right' : null; if (textAlign === null) textAlign = ''; // only the first paragraph decides } + boxMaps.push(paraBoxAttrs(ctx.resolver.paraProps(styleName))); inline.push(...convertInline(p, ctx, ctx.resolver.paraTextProps(styleName), null, true)); }; @@ -430,14 +432,38 @@ function convertHfZone(zoneEl: Element, ctx: Ctx): HfDoc { } } // Empty source paragraphs become blank lines (hardBreaks); an all-empty zone has no - // runs and no inter-paragraph break, so inline stays empty and the zone is dropped. - if (inline.length === 0) return null; + // runs and no inter-paragraph break, so inline stays empty. Keep such a zone only when + // it carries a background/rule line (a footer that is just a colored line has no text). + const box = mergeHfBox(boxMaps); + if (inline.length === 0 && Object.keys(box).length === 0) return null; const para: Node = { type: 'paragraph', content: inline }; - if (textAlign) para.attrs = { textAlign }; + const attrs: Record = {}; + if (textAlign) attrs.textAlign = textAlign; + Object.assign(attrs, box); + if (Object.keys(attrs).length) para.attrs = attrs; return { type: 'doc', content: [para] }; } +// The zone collapses several source paragraphs into one, so pick the box props that +// reproduce the visible band+rule: background/side rules from the first that declares +// them, but the bottom rule from the LAST paragraph (it sits under the whole band). +function mergeHfBox(maps: Record[]): Record { + const out: Record = {}; + const first = (key: string) => maps.find((m) => m[key] !== undefined)?.[key]; + const last = (key: string) => [...maps].reverse().find((m) => m[key] !== undefined)?.[key]; + for (const [key, val] of [ + ['backgroundColor', first('backgroundColor')], + ['borderTop', first('borderTop')], + ['borderLeft', first('borderLeft')], + ['borderRight', first('borderRight')], + ['borderBottom', last('borderBottom')], + ] as const) { + if (val !== undefined) out[key] = val; + } + return out; +} + function parseXml(xml: string): Document { const doc = new DOMParser().parseFromString(xml, 'application/xml'); if (doc.getElementsByTagName('parsererror').length) { @@ -670,9 +696,38 @@ function blockAttrs(paraProps: PropMap, textProps: PropMap, headingLevel: number // breaks, so only "page". Cell/list blocks can't carry it. if (kind === 'body' && paraProps['fo:break-before'] === 'page') attrs.breakBefore = 'page'; + // Paragraph background ("colored field") + per-side borders ("rule line"). + Object.assign(attrs, paraBoxAttrs(paraProps)); + return attrs; } +// Paragraph background + per-side border attrs (paragraphBox.ts) from resolved paraProps. +// Each key is omitted when absent so a paragraph with no box props stays clean. Borders: +// a per-side fo:border-* overrides the all-sides fo:border; 'none' ⇒ no border (undefined). +export function paraBoxAttrs(paraProps: PropMap): Record { + const out: Record = {}; + const bg = paraProps['fo:background-color']; + if (bg && bg !== 'transparent') { + const c = normalizeColor(bg); + if (c) out.backgroundColor = c; + } + const all = paraProps['fo:border']; + const sides: [string, string][] = [ + ['borderTop', 'fo:border-top'], + ['borderRight', 'fo:border-right'], + ['borderBottom', 'fo:border-bottom'], + ['borderLeft', 'fo:border-left'], + ]; + for (const [attr, key] of sides) { + const raw = paraProps[key] ?? all; + if (raw == null) continue; + const v = borderAttrFromOdf(raw, false); + if (v && v !== 'none') out[attr] = v; + } + return out; +} + // Round to 2 decimals; snap to the integer when within producer rounding noise // (6pt → LO's 0.212cm → 6.0094pt → 6). function snapPt(v: number): number { @@ -1110,7 +1165,10 @@ function listLevelDef(listStyle: Element | null, depth: number): Element | null // the editor default (0.5pt black, tolerance for producer unit rounding), 'none' for // no border, else the canonical 'pt solid #RRGGBB'. Non-solid styles are coerced // to solid (the editor only renders solid lines). -export function borderAttrFromOdf(raw: string | null | undefined): string | null { +// treatDefaultAsNull: table cells collapse the 0.5pt-black default to null (= the table's +// own default border). Paragraph borders have no such default, so they pass false to keep +// a declared thin-black rule as an explicit canonical value. +export function borderAttrFromOdf(raw: string | null | undefined, treatDefaultAsNull = true): string | null { if (!raw || raw === 'none' || raw === 'hidden') return 'none'; let widthPt: number | null = null; let color: string | null = null; @@ -1124,7 +1182,7 @@ export function borderAttrFromOdf(raw: string | null | undefined): string | null if (widthPt != null && widthPt <= 0) return 'none'; const w = Math.round((widthPt ?? 0.5) * 100) / 100; const c = (color ?? '#000000').toUpperCase(); - if (Math.abs(w - 0.5) < 0.11 && c === '#000000') return null; + if (treatDefaultAsNull && Math.abs(w - 0.5) < 0.11 && c === '#000000') return null; return `${w}pt solid ${c}`; } diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index 236309c..dc333d9 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -403,7 +403,12 @@ export class StyleResolver { const height = lengthToCm(props.getAttributeNS(NS.svg, 'height')) ?? lengthToCm(props.getAttributeNS(NS.fo, 'min-height')) ?? 0; - const spacing = lengthToCm(props.getAttributeNS(NS.fo, spacingAttr)) ?? 0; + // style:dynamic-spacing="true" makes the header↔body gap collapsible (the header + // grows into it), so LibreOffice doesn't push the body down by the full spacing — + // reserving it as a fixed body margin is what shoves the text down. Only the fixed + // (non-dynamic) spacing is added to the body margin. + const dynamic = props.getAttributeNS(NS.style, 'dynamic-spacing') === 'true'; + const spacing = dynamic ? 0 : (lengthToCm(props.getAttributeNS(NS.fo, spacingAttr)) ?? 0); return height + spacing; }; diff --git a/src/lib/storage/headerFooter.ts b/src/lib/storage/headerFooter.ts index 3080dda..a70d900 100644 --- a/src/lib/storage/headerFooter.ts +++ b/src/lib/storage/headerFooter.ts @@ -90,10 +90,12 @@ export function saveHfDoc(zone: HfZone, doc: HfDoc, variant: HfVariant = 'defaul else localStorage.setItem(KEYS[zone][variant], JSON.stringify(doc)); } -// Empty = null or a single paragraph without inline content. Empty zones render -// nothing and are skipped on export. +// Empty = null or a single paragraph without inline content AND without a visible box +// (a footer that is just a colored rule line has no text but must still render/export). export function hfIsEmpty(doc: HfDoc): boolean { if (!doc?.content?.length) return true; - const para = doc.content[0] as { content?: unknown[] } | undefined; - return !para?.content?.length; + const para = doc.content[0] as { content?: unknown[]; attrs?: Record } | undefined; + if (para?.content?.length) return false; + const a = para?.attrs ?? {}; + return !a.backgroundColor && !a.borderTop && !a.borderRight && !a.borderBottom && !a.borderLeft; } diff --git a/src/styles/editor.css b/src/styles/editor.css index ca6de51..3219d19 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -151,6 +151,16 @@ margin-bottom: 0; } +/* Paragraph "colored field" + rule line (paragraphBox.ts). Background-color and per-side + borders are inline styles from renderHTML; a bordered block gets a little padding so the + text doesn't touch the rule (matches LibreOffice's fo:padding on such paragraphs). */ +.paper .tiptap [data-pborder-top], +.paper .tiptap [data-pborder-bottom], +.paper .tiptap [data-pborder-left], +.paper .tiptap [data-pborder-right] { + padding: 0.04cm 0.08cm; +} + /* Hyperlinks (link.ts). Ctrl/Cmd+click opens them (Editor.svelte). */ .paper .tiptap a { color: #1a56db; diff --git a/tests/paragraph-box.test.ts b/tests/paragraph-box.test.ts new file mode 100644 index 0000000..e54e5b2 --- /dev/null +++ b/tests/paragraph-box.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +import { buildOdt } from '../src/lib/export/odt'; +import { importOdt } from '../src/lib/import/odt'; +import { buildDocx } from '../src/lib/export/docx'; +import { importDocx } from '../src/lib/import/docx'; +import { DEFAULT_MARGINS } from '../src/lib/storage/pageMargins'; +import { hfIsEmpty } from '../src/lib/storage/headerFooter'; + +type N = { type: string; attrs?: any; content?: N[] }; + +const bandedBody: N = { + type: 'doc', + content: [ + { type: 'paragraph', attrs: { backgroundColor: '#CCFFFF', borderTop: '2pt solid #FF0000', borderRight: '1pt solid #00B050', borderBottom: '4pt solid #0066CC', borderLeft: '1pt solid #00B050' }, content: [{ type: 'text', text: 'banded body' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'plain' }] }, + ], +}; +const bandedHeader: N = { + type: 'doc', + content: [ + { type: 'paragraph', attrs: { backgroundColor: '#CCFFFF', borderBottom: '3pt solid #0066CC', textAlign: 'right' }, content: [{ type: 'text', text: 'Letterhead' }] }, + ], +}; + +function firstBanded(doc: N): N | undefined { + return doc.content?.find((n) => n.type === 'paragraph' && !!n.content?.length); +} + +describe('paragraph background + borders round trip', () => { + it('ODF round-trips body shading + all four borders and a header rule', async () => { + const odt = await buildOdt(bandedBody as any, DEFAULT_MARGINS, 'portrait', { + header: bandedHeader as any, footer: null, pageCount: 1, + } as any); + const back: any = importOdt(odt); + + const bp = firstBanded(back.content)!; + expect(bp.attrs.backgroundColor?.toUpperCase()).toBe('#CCFFFF'); + expect(bp.attrs.borderTop).toMatch(/2pt solid #FF0000/i); + expect(bp.attrs.borderRight).toMatch(/1pt solid #00B050/i); + expect(bp.attrs.borderBottom).toMatch(/4pt solid #0066CC/i); + expect(bp.attrs.borderLeft).toMatch(/1pt solid #00B050/i); + + const hp = back.header.content[0]; + expect(hp.attrs.backgroundColor?.toUpperCase()).toBe('#CCFFFF'); + expect(hp.attrs.borderBottom).toMatch(/3pt solid #0066CC/i); + expect(hp.attrs.textAlign).toBe('right'); + }); + + it('DOCX round-trips body shading + borders (w:shd/w:pBdr) and a header rule', async () => { + const bytes = await buildDocx(bandedBody as any, DEFAULT_MARGINS, 'portrait', { + header: bandedHeader as any, footer: null, pageCount: 1, + } as any); + const back: any = importDocx(bytes); + + const bp = firstBanded(back.content)!; + expect(bp.attrs.backgroundColor?.toUpperCase()).toBe('#CCFFFF'); + expect(bp.attrs.borderTop).toMatch(/2pt solid #FF0000/i); + expect(bp.attrs.borderBottom).toMatch(/4pt solid #0066CC/i); + expect(bp.attrs.borderLeft).toMatch(/1pt solid #00B050/i); + + const hp = back.header.content[0]; + expect(hp.attrs.backgroundColor?.toUpperCase()).toBe('#CCFFFF'); + expect(hp.attrs.borderBottom).toMatch(/3pt solid #0066CC/i); + }); + + it('treats a rule-only header/footer paragraph as non-empty (so it renders/exports)', () => { + const ruleOnly: N = { type: 'doc', content: [{ type: 'paragraph', attrs: { borderTop: '4pt solid #0066CC' } }] }; + expect(hfIsEmpty(ruleOnly as any)).toBe(false); + expect(hfIsEmpty({ type: 'doc', content: [{ type: 'paragraph' }] } as any)).toBe(true); + }); + + it('drops background/borders that are absent (no accretion)', async () => { + const plain: N = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }] }; + const odt = await buildOdt(plain as any, DEFAULT_MARGINS, 'portrait'); + const back: any = importOdt(odt); + const p = firstBanded(back.content)!; + expect(p.attrs?.backgroundColor ?? null).toBeNull(); + expect(p.attrs?.borderBottom ?? null).toBeNull(); + }); +}); + +// Real LibreOffice letterhead: two header paragraphs (name = shading, address = shading + +// bottom rule) collapse to one paragraph with shading + the bottom rule; the footer is a +// single empty paragraph carrying only a top rule. Local-only fixture — skips in CI. +const FIXTURE = 'debug/bewerbung.net_anschreibenvorlage-openoffice.odt'; +describe.skipIf(!existsSync(FIXTURE))('colored header/footer from a real ODT', () => { + it('merges the header band + rule and keeps the footer rule line', () => { + const res: any = importOdt(new Uint8Array(readFileSync(FIXTURE))); + const hp = res.header.content[0]; + expect(hp.attrs.backgroundColor?.toUpperCase()).toBe('#CCFFFF'); + expect(hp.attrs.borderBottom).toMatch(/pt solid #0066CC/i); + const fp = res.footer.content[0]; + expect(fp.attrs.borderTop).toMatch(/pt solid #0066CC/i); + }); + + // dynamic-spacing="true" ⇒ the header→body gap is not reserved as body margin: top = + // page margin 0.801 + header min-height 2.2 ≈ 3.0cm (not 0.801 + 2.2 + 2.099 spacing). + it('does not reserve the dynamic header/footer spacing in the body margin', () => { + const res: any = importOdt(new Uint8Array(readFileSync(FIXTURE))); + expect(res.margins.top).toBeCloseTo(3.0, 1); + expect(res.margins.bottom).toBeCloseTo(2.22, 1); // 1.199 page + 1.021 footer height + }); +}); diff --git a/tests/unit/docx-header-floating-image.test.ts b/tests/unit/docx-header-floating-image.test.ts new file mode 100644 index 0000000..44f38ba --- /dev/null +++ b/tests/unit/docx-header-floating-image.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { zipSync, strToU8 } from 'fflate'; +import { importDocx } from '../../src/lib/import/docx'; + +// A 1x1 PNG — real bytes so the importer's magic-number sniff accepts the media. +const PNG = Uint8Array.from( + atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='), + (c) => c.charCodeAt(0), +); + +const REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'; + +function docx(files: Record): Uint8Array { + const entries: Record = {}; + for (const [k, v] of Object.entries(files)) entries[k] = typeof v === 'string' ? strToU8(v) : v; + return zipSync(entries); +} + +// Header with a floating full-page VML background ("Falzmarken"-style, position:absolute, +// A4-sized) followed by a genuine inline (as-character) logo. Word templates place page +// backgrounds/watermarks in the header this way; our one-paragraph zone can't position +// them, and at full size they overlay the whole document — so they must be dropped. +const HEADER_XML = ` + + + + + +`; + +const DOCUMENT_XML = ` + + + Body + + + + + + +`; + +const rel = (id: string, type: string, target: string) => + ``; + +function images(node: any, out: any[] = []): any[] { + if (node?.type === 'image') out.push(node); + for (const c of node?.content ?? []) images(c, out); + return out; +} + +describe('DOCX header floating image', () => { + const bytes = docx({ + 'word/document.xml': DOCUMENT_XML, + 'word/_rels/document.xml.rels': `${rel('rId1', 'header', 'header1.xml')}`, + 'word/header1.xml': HEADER_XML, + 'word/_rels/header1.xml.rels': `${rel('rIdBg', 'image', 'media/bg.png')}${rel('rIdLogo', 'image', 'media/logo.png')}`, + 'word/media/bg.png': PNG, + 'word/media/logo.png': PNG, + }); + + it('drops the full-page floating background but keeps the inline logo', () => { + const res = importDocx(bytes); + const imgs = images(res.header); + // Only the inline logo (571500 EMU ≈ 60px), never the A4-sized page background (≈794px). + expect(imgs).toHaveLength(1); + expect(imgs[0].attrs.width).toBe(60); + expect(imgs.some((i) => i.attrs.width > 700)).toBe(false); + expect([...res.warnings]).toContain('Drawings were removed'); + }); +});