feat: add paragraph frames and background
This commit is contained in:
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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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}>`;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user