feat: add paragraph frames and background

This commit is contained in:
Steffen Becker committed 2026-07-19 16:34:23 +02:00
1 parent 2a7abed917
commit f1ba14c948
17 files changed
+1053 -43

No files matched your search

@@ -0,0 +1,300 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { activeParagraphBorderPresets, type ParaBorderPreset } from '../editor/extensions/paragraphBox';
import { t } from '../i18n/i18n.svelte';
let { editor, tick }: { editor: Editor | null; tick: number } = $props();
let open = $state(false);
// Pen settings (Word-style): presets apply with the currently chosen width/color.
let widthPt = $state(0.5);
let color = $state('#000000');
const WIDTHS = [0.5, 1, 1.5, 2.25, 3];
const COLORS = [
['#000000', '#1A1A1A', '#333333', '#4D4D4D', '#666666', '#808080', '#999999', '#B3B3B3', '#CCCCCC', '#FFFFFF'],
['#C00000', '#FF0000', '#FFC000', '#FFFF00', '#92D050', '#00B050', '#00B0F0', '#0070C0', '#002060', '#7030A0'],
];
// A paragraph box has only its four edges (no inner grid lines).
type Seg = 'top' | 'right' | 'bottom' | 'left';
const SEG_PATH: Record<Seg, string> = {
top: 'M1 1H17', right: 'M17 1V17', bottom: 'M1 17H17', left: 'M1 1V17',
};
const ALL_SEGS: Seg[] = ['top', 'right', 'bottom', 'left'];
const PRESETS: { id: ParaBorderPreset | 'none'; on: Seg[] }[] = [
{ id: 'all', on: ['top', 'right', 'bottom', 'left'] },
{ id: 'top', on: ['top'] },
{ id: 'bottom', on: ['bottom'] },
{ id: 'left', on: ['left'] },
{ id: 'right', on: ['right'] },
{ id: 'none', on: [] },
];
const active = $derived(
tick >= 0 && editor ? activeParagraphBorderPresets(editor.state, { widthPt, color }) : null,
);
// Clicking an inactive preset applies the pen; an active one toggles those borders off.
function apply(id: ParaBorderPreset | 'none') {
if (!editor) return;
if (id === 'none') editor.chain().focus().setParagraphBorders('all', null).run();
else if (active?.[id]) editor.chain().focus().setParagraphBorders(id, null).run();
else editor.chain().focus().setParagraphBorders(id, { widthPt, color }).run();
}
function clickOutside(node: HTMLElement) {
function handler(e: MouseEvent) {
if (!node.contains(e.target as Node)) open = false;
}
window.addEventListener('mousedown', handler);
return { destroy() { window.removeEventListener('mousedown', handler); } };
}
</script>
<div class="border-picker" use:clickOutside>
<button
class="bp-trigger"
class:active={open}
title={t().toolbarExpanded.paragraphBorders}
aria-label={t().toolbarExpanded.paragraphBorders}
aria-expanded={open}
onclick={() => (open = !open)}
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<rect x="1.5" y="4" width="15" height="10" stroke="currentColor" stroke-width="1.3"/>
<path d="M4 7H14M4 11H11" stroke="currentColor" stroke-width="1" opacity="0.5"/>
</svg>
<svg class="bp-chevron" width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1 2.5l3 3 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
{#if open}
<div class="bp-dropdown">
<div class="bp-title">{t().toolbarExpanded.paragraphBorders}</div>
<div class="bp-presets">
{#each PRESETS as preset}
<button
class="bp-preset"
class:active={!!active?.[preset.id]}
title={t().borders[preset.id]}
aria-label={t().borders[preset.id]}
aria-pressed={!!active?.[preset.id]}
onclick={() => apply(preset.id)}
>
{#if preset.id === 'none'}
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
{#each ALL_SEGS as seg}
<path d={SEG_PATH[seg]} stroke="currentColor" stroke-width="1" opacity="0.22" stroke-dasharray="2 1.5"/>
{/each}
<circle cx="9" cy="9" r="5.5" fill="var(--color-surface)" stroke="currentColor" stroke-width="1.3"/>
<path d="M5.2 12.8L12.8 5.2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>
{:else}
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
{#each ALL_SEGS as seg}
{#if !preset.on.includes(seg)}
<path d={SEG_PATH[seg]} stroke="currentColor" stroke-width="1" opacity="0.22" stroke-dasharray="2 1.5"/>
{/if}
{/each}
{#each preset.on as seg}
<path d={SEG_PATH[seg]} stroke="currentColor" stroke-width="1.7"/>
{/each}
</svg>
{/if}
</button>
{/each}
</div>
<div class="bp-title">{t().borders.lineWidth}</div>
<div class="bp-widths">
{#each WIDTHS as w}
<button
class="bp-width"
class:active={widthPt === w}
title={t().borders.pt(w)}
aria-label={t().borders.pt(w)}
aria-pressed={widthPt === w}
onclick={() => (widthPt = w)}
>
<span class="bp-width-line" style="height: {Math.max(1, Math.round((w * 96) / 72))}px"></span>
</button>
{/each}
</div>
<div class="bp-title">{t().borders.lineColor}</div>
<div class="bp-colors">
{#each COLORS as row}
<div class="bp-color-row">
{#each row as c}
<button
class="bp-color"
class:active={color === c}
style="background: {c}"
title={c}
aria-label={c}
onclick={() => (color = c)}
></button>
{/each}
</div>
{/each}
</div>
</div>
{/if}
</div>
<style>
.border-picker {
position: relative;
}
.bp-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 1px;
height: 1.7rem;
min-width: unset;
padding: 0 3px;
border: none;
border-radius: calc(var(--radius) - 2px);
background: transparent;
color: var(--color-text);
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.bp-trigger:hover,
.bp-trigger.active {
background: var(--color-btn-hover);
}
.bp-chevron {
flex-shrink: 0;
}
.bp-dropdown {
position: absolute;
top: calc(100% + 3px);
left: 0;
z-index: 200;
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.bp-title {
padding: 0.1rem 0.2rem 0.2rem;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text);
font-family: var(--font-sans);
user-select: none;
}
.bp-presets {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 2px;
}
.bp-preset {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.9rem;
height: 1.9rem;
min-width: unset;
padding: 0;
border: 1px solid var(--color-border);
border-radius: calc(var(--radius) - 2px);
background: transparent;
color: var(--color-text);
cursor: pointer;
transition: background 0.1s;
}
.bp-preset:hover {
background: var(--color-btn-hover);
}
.bp-preset.active {
outline: 2px solid var(--color-primary);
outline-offset: -1px;
}
.bp-widths {
display: flex;
gap: 2px;
}
.bp-width {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 1;
height: 1.4rem;
min-width: unset;
padding: 0 4px;
border: 1px solid var(--color-border);
border-radius: calc(var(--radius) - 2px);
background: transparent;
color: var(--color-text);
cursor: pointer;
transition: background 0.1s;
}
.bp-width:hover {
background: var(--color-btn-hover);
}
.bp-width.active {
outline: 2px solid var(--color-primary);
outline-offset: -1px;
}
.bp-width-line {
display: block;
width: 100%;
background: currentColor;
}
.bp-colors {
display: flex;
flex-direction: column;
gap: 2px;
}
.bp-color-row {
display: flex;
gap: 2px;
}
.bp-color {
width: 18px;
height: 18px;
min-width: unset;
padding: 0;
border: 1px solid var(--color-border);
border-radius: 2px;
cursor: pointer;
transition: transform 0.05s;
}
.bp-color:hover {
transform: scale(1.15);
}
.bp-color.active {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
</style>
+56 -6
View File
@@ -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 @@
</svg>
{/snippet}
</ColorPicker>
<ColorPicker
{editor}
currentColor={currentParaBackground}
defaultColor="#FFFF00"
title={t().toolbarExpanded.paragraphShading}
chevronTitle={t().toolbarExpanded.chooseParagraphShading}
clearLabel={t().toolbarExpanded.noColor}
bind:open={paraShadeOpen}
onOpen={() => 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()}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3 13h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<rect x="3" y="3" width="10" height="7" rx="1" fill="currentColor" opacity="0.3" stroke="currentColor" stroke-width="1.2"/>
</svg>
{/snippet}
</ColorPicker>
<ParagraphBorderPicker {editor} {tick} />
</div>
<div class="toolbar-separator"></div>
+4
View File
@@ -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,
@@ -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
+168
View File
@@ -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
// `'<W>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<Side, { data: string; css: string }> = {
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<ParaBorderPreset, Side[]> = {
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<ReturnType> {
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<string, unknown>) => {
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<string, unknown>) => {
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<Record<Side, string | null>> = {};
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<BorderSpec, null>,
): Record<ParaBorderPreset | 'none', boolean> | 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<ParaBorderPreset | 'none', boolean>;
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;
}
@@ -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';
+17
View File
@@ -579,6 +579,21 @@ const HEADING_LEVEL: Record<number, (typeof HeadingLevel)[keyof typeof HeadingLe
type ParaOpts = { numbering?: { reference: string; level: number }; indentLeftTwip?: number; forceBold?: boolean };
// Paragraph background ("colored field") → w:shd; per-side borders ("rule line") → w:pBdr.
function paraShadingOf(attrs: TiptapNode['attrs']) {
const c = typeof attrs?.backgroundColor === 'string' ? hexColor(attrs.backgroundColor) : undefined;
return c ? { type: ShadingType.CLEAR, fill: c, color: 'auto' } : undefined;
}
function paraBordersOf(attrs: TiptapNode['attrs']) {
const out: { top?: IBorderOptions; right?: IBorderOptions; bottom?: IBorderOptions; left?: IBorderOptions } = {};
for (const [attr, side] of [['borderTop', 'top'], ['borderRight', 'right'], ['borderBottom', 'bottom'], ['borderLeft', 'left']] as const) {
const b = parseBorderAttr(attrs?.[attr] as string | null);
if (b && b !== 'none') out[side] = { style: BorderStyle.SINGLE, size: Math.max(2, Math.round(b.widthPt * 8)), color: hexColor(b.color) ?? '000000', space: 1 };
}
return Object.keys(out).length ? out : undefined;
}
function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph {
const attrs = node.attrs ?? {};
const indent: Writable<IIndentAttributesProperties> = {};
@@ -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),
});
+157 -15
View File
@@ -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 (PBX<bg|bt|br|bb|bl>PBX), 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 <text:p> to a <text:table-of-content>. 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 ('<W>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 ('<W>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 '<W>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 <style:paragraph-properties> (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 (/<style:paragraph-properties\b[^>]*\/>/.test(s))
return s.replace(/<style:paragraph-properties\b([^>]*?)\s*\/>/, `<style:paragraph-properties$1 ${props}/>`);
if (/<style:paragraph-properties\b/.test(s))
return s.replace(/<style:paragraph-properties\b([^>]*)>/, `<style:paragraph-properties$1 ${props}>`);
if (/^<style:style\b[^>]*\/>\s*$/.test(s))
return s.replace(/\s*\/>\s*$/, `><style:paragraph-properties ${props}/></style:style>`);
if (/<style:text-properties\b/.test(s))
return s.replace(/<style:text-properties\b/, `<style:paragraph-properties ${props}/><style:text-properties`);
return s.replace('</style:style>', `<style:paragraph-properties ${props}/></style:style>`);
}
// 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<string, string>();
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)
: `<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="${source || 'Standard'}"><style:paragraph-properties ${props}/></style:style>`,
);
return name;
};
const pbxRe = new RegExp(`${PBX}([^${PBX}]*)${PBX}`);
content = content.replace(
new RegExp(`<text:(p|h)\\b([^>]*)>([\\s\\S]*?)</text:\\1>`, '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 `<text:${tag}${newAttrs}>${cleaned}</text:${tag}>`;
},
);
// 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('</office:automatic-styles>', `${minted.join('')}</office:automatic-styles>`);
}
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(`<style:${kind}-style>[\\s\\S]*?</style:${kind}-style>`),
`<style:${kind}-style><style:header-footer-properties fo:min-height="${minH}cm" ${spacingAttr}="0cm" style:dynamic-spacing="false"/></style:${kind}-style>`,
);
const align = hfAlign(para);
if (align) {
const props = hfParaProps(para);
if (props.length) {
const styleName = kind === 'header' ? 'Header' : 'Footer';
styles = styles.replace(
new RegExp(`(<style:style style:name="${styleName}"[^>]*?)/>`),
`$1><style:paragraph-properties fo:text-align="${align}"/></style:style>`,
`$1><style:paragraph-properties ${props.join(' ')}/></style:style>`,
);
}
};
@@ -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(`<style:style style:name="${paraStyle}" style:family="paragraph" style:parent-style-name="${parent}"><style:paragraph-properties fo:text-align="${align}"/></style:style>`);
mint(`<style:style style:name="${paraStyle}" style:family="paragraph" style:parent-style-name="${parent}"><style:paragraph-properties ${props.join(' ')}/></style:style>`);
}
return `<style:${kind}-${suffix}><text:p text:style-name="${paraStyle}">${inner}</text:p></style:${kind}-${suffix}>`;
}
+3
View File
@@ -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',
+3
View File
@@ -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',
+77 -11
View File
@@ -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<string, string> {
const out: Record<string, string> = {};
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<string, string>[] = [];
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<string, string> = {};
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<string, string>[]): Record<string, string> {
const out: Record<string, string> = {};
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;
}
+63 -5
View File
@@ -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<string, string>[] = [];
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<string, string> = {};
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<string, string>[]): Record<string, string> {
const out: Record<string, string> = {};
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<string, string> {
const out: Record<string, string> = {};
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 '<W>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}`;
}
+6 -1
View File
@@ -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;
};
+6 -4
View File
@@ -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<string, unknown> } | undefined;
if (para?.content?.length) return false;
const a = para?.attrs ?? {};
return !a.backgroundColor && !a.borderTop && !a.borderRight && !a.borderBottom && !a.borderLeft;
}
+10
View File
@@ -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;
+104
View File
@@ -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
});
});
@@ -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<string, string | Uint8Array>): Uint8Array {
const entries: Record<string, Uint8Array> = {};
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 = `<?xml version="1.0"?>
<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
xmlns:r="${REL}"
xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:v="urn:schemas-microsoft-com:vml">
<w:p>
<w:r><w:pict><v:shape style="position:absolute;width:595pt;height:842pt;z-index:-1"><v:imagedata r:id="rIdBg"/></v:shape></w:pict></w:r>
<w:r><w:drawing><wp:inline><wp:extent cx="571500" cy="190500"/><a:blip r:embed="rIdLogo"/></wp:inline></w:drawing></w:r>
</w:p>
</w:hdr>`;
const DOCUMENT_XML = `<?xml version="1.0"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="${REL}">
<w:body>
<w:p><w:r><w:t>Body</w:t></w:r></w:p>
<w:sectPr>
<w:headerReference w:type="default" r:id="rId1"/>
<w:pgSz w:w="11906" w:h="16838"/>
<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="567" w:footer="567"/>
</w:sectPr>
</w:body>
</w:document>`;
const rel = (id: string, type: string, target: string) =>
`<Relationship Id="${id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/${type}" Target="${target}"/>`;
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': `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${rel('rId1', 'header', 'header1.xml')}</Relationships>`,
'word/header1.xml': HEADER_XML,
'word/_rels/header1.xml.rels': `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${rel('rIdBg', 'image', 'media/bg.png')}${rel('rIdLogo', 'image', 'media/logo.png')}</Relationships>`,
'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');
});
});