feat: add table frame menue

This commit is contained in:
Steffen Becker committed 2026-07-04 00:00:44 +02:00
1 parent 0aa9399f93
commit 15b9386eb7
14 files changed
+963 -6

No files matched your search

+1
View File
@@ -34,6 +34,7 @@
**Insert**
- Tables: insert via size picker, Word-style row/column drag-resize, add / delete rows & columns, delete table, cell borders, merge cells and split cells (N×M, Word/LibreOffice-style), cell background shading, header-row toggle (bold + shading); a table splits cleanly across page boundaries
- Table border control (Word/LibreOffice-style): per-side cell borders with presets (all / outside / inside / single edges / none), line width and color; buttons show active states matching the current pen and toggle borders off; round-trips to ODF `fo:border-*` and DOCX `w:tcBorders`
- Images: inline or floating with text wrap (left / right / top-bottom), resize handles, rotation, live size badge; insert via toolbar, drag-and-drop, or paste
- Special characters picker
- Table of contents: generated from headings (H1–H3) with live page numbers and dot leaders, click an entry to jump to its heading; round-trips to ODF `text:table-of-content` and a Word TOC field
+310
View File
@@ -0,0 +1,310 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { activeBorderPresets, type BorderPreset } from '../editor/extensions/tableCellBorders';
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];
// Greyscale + standard hues (the first two ColorPicker palette rows).
const COLORS = [
['#000000', '#1A1A1A', '#333333', '#4D4D4D', '#666666', '#808080', '#999999', '#B3B3B3', '#CCCCCC', '#FFFFFF'],
['#C00000', '#FF0000', '#FFC000', '#FFFF00', '#92D050', '#00B050', '#00B0F0', '#0070C0', '#002060', '#7030A0'],
];
// Preset icons: 6 segments (outer edges + the two inner grid lines); "on" segments
// draw solid, the rest as faint guides.
type Seg = 'top' | 'right' | 'bottom' | 'left' | 'h' | 'v';
const SEG_PATH: Record<Seg, string> = {
top: 'M2 2H16', right: 'M16 2V16', bottom: 'M2 16H16', left: 'M2 2V16',
h: 'M2 9H16', v: 'M9 2V16',
};
const ALL_SEGS: Seg[] = ['top', 'right', 'bottom', 'left', 'h', 'v'];
const PRESETS: { id: BorderPreset | 'none'; title: string; on: Seg[] }[] = [
{ id: 'all', title: 'All borders', on: ['top', 'right', 'bottom', 'left', 'h', 'v'] },
{ id: 'outer', title: 'Outside borders', on: ['top', 'right', 'bottom', 'left'] },
{ id: 'inner', title: 'Inside borders', on: ['h', 'v'] },
{ id: 'innerH', title: 'Inside horizontal border', on: ['h'] },
{ id: 'innerV', title: 'Inside vertical border', on: ['v'] },
{ id: 'top', title: 'Top border', on: ['top'] },
{ id: 'bottom', title: 'Bottom border', on: ['bottom'] },
{ id: 'left', title: 'Left border', on: ['left'] },
{ id: 'right', title: 'Right border', on: ['right'] },
{ id: 'none', title: 'No border', on: [] },
];
// Word-like button states, re-read per transaction (tick) and pen change: a preset
// is active when its boundaries all render the pen border; 'none' when borderless.
const active = $derived(
tick >= 0 && editor ? activeBorderPresets(editor.state, { widthPt, color }) : null,
);
// Clicking an inactive preset applies the pen; clicking an active one toggles those
// borders off (Word behavior). The panel stays open so the states visibly update.
function apply(id: BorderPreset | 'none') {
if (!editor) return;
if (id === 'none') editor.chain().focus().setTableBorders('all', null).run();
else if (active?.[id]) editor.chain().focus().setTableBorders(id, null).run();
else editor.chain().focus().setTableBorders(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="Borders"
aria-label="Borders"
aria-expanded={open}
onclick={() => (open = !open)}
>
<svg width="16" height="16" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<rect x="2.5" y="2.5" width="13" height="13" stroke="currentColor" stroke-width="1.3"/>
<path d="M9 2.5V15.5M2.5 9H15.5" stroke="currentColor" stroke-width="1.1" stroke-dasharray="1.6 1.6"/>
</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">Borders</div>
<div class="bp-presets">
{#each PRESETS as preset}
<button
class="bp-preset"
class:active={!!active?.[preset.id]}
title={preset.title}
aria-label={preset.title}
aria-pressed={!!active?.[preset.id]}
onclick={() => apply(preset.id)}
>
{#if preset.id === 'none'}
<!-- Prohibition sign over a faint grid: unmistakably "remove borders". -->
<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="1.4 1.4"/>
{/each}
<circle cx="9" cy="9" r="4.6" fill="var(--color-surface)" stroke="currentColor" stroke-width="1.3"/>
<path d="M5.9 12.1L12.1 5.9" 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="1.4 1.4"/>
{/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">Line width</div>
<div class="bp-widths">
{#each WIDTHS as w}
<button
class="bp-width"
class:active={widthPt === w}
title="{w}pt"
aria-label="{w}pt"
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">Line color</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(5, 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>
+3
View File
@@ -3,6 +3,7 @@
import { CellSelection } from '@tiptap/pm/tables';
import { isHeaderRowStyled } from '../editor/extensions/tableHeaderRow';
import ColorPicker from './ColorPicker.svelte';
import TableBorderPicker from './TableBorderPicker.svelte';
let {
editor,
@@ -210,6 +211,8 @@
{/snippet}
</ColorPicker>
<TableBorderPicker {editor} {tick} />
<span class="tt-sep"></span>
<button
+3
View File
@@ -36,6 +36,7 @@ import { TableColumnResize } from './extensions/tableColumnResize';
import { TableRowResize } from './extensions/tableRowResize';
import { TableSplit } from './extensions/tableSplit';
import { TableCellBackground } from './extensions/tableCellBackground';
import { TableCellBorders } from './extensions/tableCellBorders';
import { TableHeaderRow } from './extensions/tableHeaderRow';
import { TrailingNode } from './extensions/trailingNode';
import { Image } from './extensions/image';
@@ -98,6 +99,8 @@ export const extensions = [
TableSplit,
// Cell shading: backgroundColor attr on tableCell/tableHeader (→ fo:background-color).
TableCellBackground,
// Word-style border presets: per-side border attrs on cells (→ fo:border-*).
TableCellBorders,
// "Header row" styling preset: bold + light shading on the first row (toggle).
TableHeaderRow,
// Guarantees a paragraph after a trailing table so the cursor isn't trapped
@@ -0,0 +1,239 @@
import { Extension } from '@tiptap/core';
import type { CommandProps } from '@tiptap/core';
import type { EditorState } from '@tiptap/pm/state';
import { selectedRect, isInTable } from '@tiptap/pm/tables';
// Word/LibreOffice table borders: per-side attrs (`borderTop/Right/Bottom/Left`) on
// table cells. `null` = the table default (0.5pt solid black), `'none'` = no border,
// else the canonical `'<W>pt solid #RRGGBB'`. Round-trips to ODF fo:border-* and DOCX
// w:tcBorders (export/odt.ts exportTable, import/odt.ts convertTable, export/import
// docx.ts). setTableBorders applies a Word-style preset to the selected cell region,
// writing BOTH sides of every affected boundary (incl. the facing side of neighbour
// cells outside the region) so collapsed borders never disagree between two cells.
export const DEFAULT_BORDER_WIDTH_PT = 0.5;
export const DEFAULT_BORDER_COLOR = '#000000';
export type BorderPreset =
| 'all' | 'outer' | 'inner' | 'innerH' | 'innerV'
| 'top' | 'bottom' | 'left' | 'right';
// null = no border ('none').
export type BorderSpec = { widthPt: number; color: string } | null;
export const BORDER_SIDES = ['borderTop', 'borderRight', 'borderBottom', 'borderLeft'] as const;
export type BorderSide = (typeof BORDER_SIDES)[number];
declare module '@tiptap/core' {
interface Commands<ReturnType> {
tableCellBorders: {
setTableBorders: (preset: BorderPreset, spec: BorderSpec) => ReturnType;
};
}
}
// Canonical attr value for a spec; the editor default collapses to null.
export function borderAttrValue(spec: BorderSpec): string | null {
if (!spec) return 'none';
const w = Math.round(spec.widthPt * 100) / 100;
const color = spec.color.toUpperCase();
if (w === DEFAULT_BORDER_WIDTH_PT && color === DEFAULT_BORDER_COLOR) return null;
return `${w}pt solid ${color}`;
}
// null = table default, 'none' = no border, else width/color of a custom border.
export function parseBorderAttr(
value: string | null | undefined,
): { widthPt: number; color: string } | 'none' | null {
if (value == null) return null;
if (value === 'none') return 'none';
const m = /^([\d.]+)pt solid (#[0-9A-Fa-f]{6})$/.exec(value);
if (!m) return null;
return { widthPt: parseFloat(m[1]), color: m[2].toUpperCase() };
}
// 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 {
const b = parseBorderAttr(value);
if (b === null) return null;
if (b === 'none') return 'none';
const px = Math.max(1, Math.round((b.widthPt * 96) / 72));
return `${px}px solid ${b.color}`;
}
const SIDE_META: Record<BorderSide, { data: string; css: string }> = {
borderTop: { data: 'data-border-top', css: 'border-top' },
borderRight: { data: 'data-border-right', css: 'border-right' },
borderBottom: { data: 'data-border-bottom', css: 'border-bottom' },
borderLeft: { data: 'data-border-left', css: 'border-left' },
};
// Walks every boundary a preset targets in the selected rect, reporting the two
// facing (cellPos, side) pairs of each; a pos is null at the table edge. Boundaries
// running inside a merged cell are skipped. Shared by the setTableBorders command
// and the active-preset reader below.
function forEachBoundary(
rect: ReturnType<typeof selectedRect>,
preset: BorderPreset,
cb: (aPos: number | null, aSide: BorderSide, bPos: number | null, bSide: BorderSide) => void,
): void {
const { map } = rect;
const wants = {
topEdge: preset === 'all' || preset === 'outer' || preset === 'top',
bottomEdge: preset === 'all' || preset === 'outer' || preset === 'bottom',
leftEdge: preset === 'all' || preset === 'outer' || preset === 'left',
rightEdge: preset === 'all' || preset === 'outer' || preset === 'right',
innerH: preset === 'all' || preset === 'inner' || preset === 'innerH',
innerV: preset === 'all' || preset === 'inner' || preset === 'innerV',
};
// Horizontal boundaries: grid line y separates row y-1 (above) from row y (below).
for (let y = rect.top; y <= rect.bottom; y++) {
const isTop = y === rect.top;
const isBottom = y === rect.bottom;
if (isTop && !wants.topEdge) continue;
if (isBottom && !wants.bottomEdge) continue;
if (!isTop && !isBottom && !wants.innerH) continue;
for (let x = rect.left; x < rect.right; x++) {
const below = y < map.height ? map.map[y * map.width + x] : null;
const above = y > 0 ? map.map[(y - 1) * map.width + x] : null;
if (above === below) continue; // grid line runs inside a merged cell
cb(above, 'borderBottom', below, 'borderTop');
}
}
// Vertical boundaries: grid line x separates column x-1 (left) from column x (right).
for (let x = rect.left; x <= rect.right; x++) {
const isLeft = x === rect.left;
const isRight = x === rect.right;
if (isLeft && !wants.leftEdge) continue;
if (isRight && !wants.rightEdge) continue;
if (!isLeft && !isRight && !wants.innerV) continue;
for (let y = rect.top; y < rect.bottom; y++) {
const right = x < map.width ? map.map[y * map.width + x] : null;
const left = x > 0 ? map.map[y * map.width + (x - 1)] : null;
if (left === right) continue;
cb(left, 'borderRight', right, 'borderLeft');
}
}
}
export function setTableBorders(preset: BorderPreset, spec: BorderSpec) {
return ({ state, tr, dispatch }: CommandProps): boolean => {
if (!isInTable(state)) return false;
if (!dispatch) return true;
const rect = selectedRect(state);
const value = borderAttrValue(spec);
// pos (relative to tableStart) → attr changes, deduped across grid slots of a span.
const changes = new Map<number, Record<string, string | null>>();
const set = (pos: number | null, side: BorderSide) => {
if (pos == null) return;
const c = changes.get(pos) ?? {};
c[side] = value;
changes.set(pos, c);
};
forEachBoundary(rect, preset, (aPos, aSide, bPos, bSide) => {
set(aPos, aSide);
set(bPos, bSide);
});
// setNodeMarkup doesn't shift positions, so all cell positions stay valid.
for (const [pos, attrs] of changes) {
const cell = tr.doc.nodeAt(rect.tableStart + pos);
if (!cell) continue;
tr.setNodeMarkup(rect.tableStart + pos, undefined, { ...cell.attrs, ...attrs });
}
dispatch(tr);
return true;
};
}
export const BORDER_PRESETS: BorderPreset[] = [
'all', 'outer', 'inner', 'innerH', 'innerV', 'top', 'bottom', 'left', 'right',
];
// What a boundary renders: the collapse winner of its two facing cell sides
// (a border beats 'none', wider beats narrower; attr null = the table default).
type EffectiveBorder = { widthPt: number; color: string } | 'none';
// Word-like active states for the picker: a preset is active when every boundary it
// targets renders exactly the pen border (width + color) — so with the default thin
// pen a fresh table lights up every preset; picking a thicker pen turns them off.
// 'none' is active when the whole region renders borderless. null outside a table.
export function activeBorderPresets(
state: EditorState,
spec: Exclude<BorderSpec, null>,
): Record<BorderPreset | 'none', boolean> | null {
if (!isInTable(state)) return null;
const rect = selectedRect(state);
const sideEff = (pos: number | null, side: BorderSide): EffectiveBorder | null => {
if (pos == null) return null;
const b = parseBorderAttr(state.doc.nodeAt(rect.tableStart + pos)?.attrs[side] as string | null);
return b ?? { widthPt: DEFAULT_BORDER_WIDTH_PT, color: DEFAULT_BORDER_COLOR };
};
const boundaryEff = (aPos: number | null, aSide: BorderSide, bPos: number | null, bSide: BorderSide): EffectiveBorder => {
let best: EffectiveBorder = 'none';
for (const v of [sideEff(aPos, aSide), sideEff(bPos, bSide)]) {
if (v == null || v === 'none') continue;
if (best === 'none' || v.widthPt > best.widthPt) best = v;
}
return best;
};
const matchesPen = (v: EffectiveBorder) =>
v !== 'none' && Math.abs(v.widthPt - spec.widthPt) < 0.01 && v.color === spec.color.toUpperCase();
const out = {} as Record<BorderPreset | 'none', boolean>;
for (const preset of BORDER_PRESETS) {
let n = 0;
let active = true;
forEachBoundary(rect, preset, (aPos, aSide, bPos, bSide) => {
n++;
if (!matchesPen(boundaryEff(aPos, aSide, bPos, bSide))) active = false;
});
out[preset] = n > 0 && active;
}
let n = 0;
let allNone = true;
forEachBoundary(rect, 'all', (aPos, aSide, bPos, bSide) => {
n++;
if (boundaryEff(aPos, aSide, bPos, bSide) !== 'none') allNone = false;
});
out.none = n > 0 && allNone;
return out;
}
export const TableCellBorders = Extension.create({
name: 'tableCellBorders',
addGlobalAttributes() {
return [
{
types: ['tableCell', 'tableHeader'],
attributes: 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) return {};
return { [SIDE_META[side].data]: v, style: `${SIDE_META[side].css}: ${css}` };
},
},
]),
),
},
];
},
addCommands() {
return {
setTableBorders: (preset: BorderPreset, spec: BorderSpec) => setTableBorders(preset, spec),
};
},
});
+15 -1
View File
@@ -18,6 +18,7 @@ import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { HF_DISTANCE_CM, hfIsEmpty } from '../storage/headerFooter';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCellBorders';
import { orderedTypeDef } from '../utils/orderedListTypes';
import { normalizeColor, HEADING_STYLE_OVERRIDES, type HfExport } from './odt';
@@ -608,6 +609,14 @@ function columnWidthsCm(node: TiptapNode, contentWidthCm: number): number[] | un
const cellBorder: IBorderOptions = { style: BorderStyle.SINGLE, size: 4, color: '000000' }; // 0.5pt
// A cell's per-side border attr → w:tcBorders options (size in eighth-points).
function docxCellBorder(attrs: Record<string, unknown> | undefined, side: BorderSide): IBorderOptions {
const b = parseBorderAttr(attrs?.[side] as string | null);
if (b === 'none') return { style: BorderStyle.NONE, size: 0, color: 'auto' };
if (b) return { style: BorderStyle.SINGLE, size: Math.max(2, Math.round(b.widthPt * 8)), color: hexColor(b.color) ?? '000000' };
return cellBorder;
}
function cellBlocksToDocx(content: TiptapNode[] = [], headerBold: boolean, num: Numbering, contentWidthCm: number): (Paragraph | Table)[] {
const out: (Paragraph | Table)[] = [];
for (const child of content) {
@@ -646,7 +655,12 @@ function tableToDocx(node: TiptapNode, contentWidthCm: number, num: Numbering):
rowSpan: rowspan > 1 ? rowspan : undefined,
shading: fill ? { type: ShadingType.CLEAR, fill } : undefined,
width: widthTwip ? { size: widthTwip, type: WidthType.DXA } : undefined,
borders: { top: cellBorder, bottom: cellBorder, left: cellBorder, right: cellBorder },
borders: {
top: docxCellBorder(cell.attrs, 'borderTop'),
bottom: docxCellBorder(cell.attrs, 'borderBottom'),
left: docxCellBorder(cell.attrs, 'borderLeft'),
right: docxCellBorder(cell.attrs, 'borderRight'),
},
children: cellBlocksToDocx(cell.content, bg === HEADER_SHADE, num, contentWidthCm),
}));
}
+7
View File
@@ -4,6 +4,7 @@ import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc } from '../storage/headerFooter';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { BORDER_SIDES, parseBorderAttr } from '../editor/extensions/tableCellBorders';
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
import { DEFAULT_ORDERED_TYPE, orderedTypeDef } from '../utils/orderedListTypes';
@@ -1286,6 +1287,12 @@ function exportTable(node: TiptapNode, doc: OdtDocument, contentWidthCm: number,
const c = normalizeColor(bg);
if (c) opts.backgroundColor = c;
}
// Per-side borders → fo:border-* (null = the table default border below).
for (const side of BORDER_SIDES) {
const b = parseBorderAttr(cell.attrs?.[side] as string | null);
if (b === 'none') opts[side] = 'none';
else if (b) opts[side] = `${b.widthPt}pt solid ${normalizeColor(b.color) ?? b.color}`;
}
// Header-row cells render bold via CSS (presentational), so bake bold into the
// runs on export to keep Word/LibreOffice consistent (incl. freshly-typed text).
const headerBold = bg === HEADER_SHADE;
+42 -1
View File
@@ -722,14 +722,41 @@ function convertPict(pict: Element, ctx: Ctx): Node | null {
}
// ---- tables -----------------------------------------------------------------
// A w:tcBorders/w:tblBorders side element → border attr value: null = the editor
// default (0.5pt black), 'none', or '<W>pt solid #RRGGBB' (w:sz is eighth-points).
// undefined when the element is absent (side not declared at this level).
function docxBorderAttr(el: Element | null): string | null | undefined {
if (!el) return undefined;
const val = el.getAttributeNS(W, 'val');
if (!val || val === 'none' || val === 'nil') return 'none';
const sz = intAttr(el, W, 'sz');
const w = Math.round(((sz != null ? sz / 8 : 0.5)) * 100) / 100;
const c = (hexColor(el.getAttributeNS(W, 'color')) ?? '#000000').toUpperCase();
if (Math.abs(w - 0.5) < 0.11 && c === '#000000') return null;
return `${w}pt solid ${c}`;
}
function convertTable(tbl: Element, ctx: Ctx): Node | null {
const grid = fc(tbl, 'tblGrid');
const weights = grid ? fcAll(grid, 'gridCol').map((g) => Math.max(1, intAttr(g, W, 'w') ?? 1)) : null;
const useWeights = weights && weights.length ? weights : null;
// Table-level border defaults; per-cell w:tcBorders override per side. Edge cells
// fall back to the outer sides, interior cells to insideH/insideV.
const tblBorders = fc(fc(tbl, 'tblPr'), 'tblBorders');
const tblSide = (name: string) => docxBorderAttr(fc(tblBorders, name));
const tblDef = {
top: tblSide('top'), bottom: tblSide('bottom'), left: tblSide('left'),
right: tblSide('right'), insideH: tblSide('insideH'), insideV: tblSide('insideV'),
};
const gridWidth = useWeights?.length ?? null;
const rows: Node[] = [];
const pending: (Node | null)[] = []; // origin cell per grid column, for vMerge spans
for (const tr of fcAll(tbl, 'tr')) {
const trs = fcAll(tbl, 'tr');
for (let ri = 0; ri < trs.length; ri++) {
const tr = trs[ri];
const cells: Node[] = [];
let col = 0;
for (const tc of fcAll(tr, 'tc')) {
@@ -748,6 +775,20 @@ function convertTable(tbl: Element, ctx: Ctx): Node | null {
const blocks = convertBlocks(Array.from(tc.children), ctx, 'cell', bg === HEADER_SHADE);
const attrs: Record<string, unknown> = { colspan, rowspan: 1 };
if (bg) attrs.backgroundColor = bg;
const tcBorders = fc(tcPr, 'tcBorders');
const isRight = gridWidth != null && col + colspan >= gridWidth;
const resolve = (name: string, tblVal: string | null | undefined) => {
const own = docxBorderAttr(fc(tcBorders, name));
return own !== undefined ? own : tblVal;
};
const sides: [string, string | null | undefined][] = [
['borderTop', resolve('top', ri === 0 ? tblDef.top : tblDef.insideH)],
['borderBottom', resolve('bottom', ri === trs.length - 1 ? tblDef.bottom : tblDef.insideH)],
['borderLeft', resolve('left', col === 0 ? tblDef.left : tblDef.insideV)],
['borderRight', resolve('right', isRight ? tblDef.right : tblDef.insideV)],
];
// undefined (nothing declared) and null (= editor default) both leave the attr off.
for (const [attr, v] of sides) if (typeof v === 'string') attrs[attr] = v;
if (useWeights) attrs.colwidth = useWeights.slice(col, col + colspan);
const cell: Node = { type: 'tableCell', attrs, content: blocks.length ? blocks : [{ type: 'paragraph' }] };
cells.push(cell);
+35 -2
View File
@@ -833,6 +833,28 @@ function listLevelDef(listStyle: Element | null, depth: number): Element | null
// ---- tables ------------------------------------------------------------------------
// ODF border ("0.5pt solid #000000" / "none" / absent) → border attr value: null for
// 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 {
if (!raw || raw === 'none' || raw === 'hidden') return 'none';
let widthPt: number | null = null;
let color: string | null = null;
let styleTok: string | null = null;
for (const part of raw.trim().split(/\s+/)) {
if (/^-?[\d.]+\s*(pt|cm|mm|in|px|pc)?$/.test(part)) widthPt = lengthToPt(part);
else if (part.startsWith('#')) color = normalizeColor(part) ?? part;
else styleTok = part;
}
if (styleTok === 'none' || styleTok === 'hidden') return 'none';
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;
return `${w}pt solid ${c}`;
}
function convertTable(el: Element, ctx: Ctx): Node | null {
const weights = columnWeights(el, ctx.resolver);
@@ -851,13 +873,24 @@ function convertTable(el: Element, ctx: Ctx): Node | null {
const colspan = parseInt(cellEl.getAttributeNS(NS.table, 'number-columns-spanned') ?? '1', 10) || 1;
const rowspan = parseInt(cellEl.getAttributeNS(NS.table, 'number-rows-spanned') ?? '1', 10) || 1;
const rawBg = ctx.resolver.cellBackgroundColor(cellEl.getAttributeNS(NS.table, 'style-name'));
const cellStyleName = cellEl.getAttributeNS(NS.table, 'style-name');
const rawBg = ctx.resolver.cellBackgroundColor(cellStyleName);
const backgroundColor = rawBg ? normalizeColor(rawBg) ?? rawBg : null;
// Per-side borders; an undeclared side means no border in ODF → 'none'.
// Only null (= the editor's 0.5pt-black default) is left off the attrs.
const rawBorders = ctx.resolver.cellBorders(cellStyleName);
const borders: Record<string, string> = {};
for (const [attr, side] of [
['borderTop', 'top'], ['borderRight', 'right'], ['borderBottom', 'bottom'], ['borderLeft', 'left'],
] as const) {
const v = borderAttrFromOdf(rawBorders[side]);
if (v !== null) borders[attr] = v;
}
// Header-shaded cells render bold by default (CSS); convert their runs like headings
// so a baked-bold run needs no mark and only an explicitly normal run gets one.
const blocks = convertBlocks(Array.from(cellEl.children), ctx, 'cell', backgroundColor === HEADER_SHADE);
for (let r = 0; r < repeated; r++) {
const attrs: Record<string, unknown> = { colspan, rowspan };
const attrs: Record<string, unknown> = { colspan, rowspan, ...borders };
if (backgroundColor) attrs.backgroundColor = backgroundColor;
if (weights) attrs.colwidth = weights.slice(colIndex, colIndex + colspan);
cells.push({
+15
View File
@@ -248,6 +248,21 @@ export class StyleResolver {
return c && c !== 'transparent' && c !== 'none' ? c : null;
}
// A cell style's raw fo:border-* values (per-side overriding the fo:border
// shorthand). Sides the style doesn't declare stay undefined (= no border in ODF).
cellBorders(styleName: string | null): Partial<Record<'top' | 'right' | 'bottom' | 'left', string>> {
if (!styleName) return {};
const misc = this.merged('table-cell', styleName).misc;
const all = misc['fo:border'];
const pick = (side: string) => misc[`fo:border-${side}`] ?? all;
const out: Partial<Record<'top' | 'right' | 'bottom' | 'left', string>> = {};
for (const side of ['top', 'right', 'bottom', 'left'] as const) {
const v = pick(side);
if (v != null) out[side] = v;
}
return out;
}
listStyle(name: string | null): Element | null {
return name ? this.listStyles.get(name) ?? null : null;
}
+7 -1
View File
@@ -51,7 +51,7 @@ describe('DOCX export → import round trip', () => {
{ type: 'textBox', attrs: { width: 192, height: 96, wrap: 'right', shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 }, content: [para('ellipse text')] },
{ type: 'table', content: [
{ type: 'tableRow', content: [headerCell('Name', { colwidth: [6] }), headerCell('Qty', { colwidth: [3] })] },
{ type: 'tableRow', attrs: { rowHeight: 40 }, content: [cell('Widget', { backgroundColor: '#FFFF00', rowspan: 2 }), cell('1')] },
{ type: 'tableRow', attrs: { rowHeight: 40 }, content: [cell('Widget', { backgroundColor: '#FFFF00', rowspan: 2 }), cell('1', { borderTop: 'none', borderRight: '2.25pt solid #FF0000' })] },
{ type: 'tableRow', content: [cell('2')] },
] },
],
@@ -157,6 +157,12 @@ describe('DOCX export → import round trip', () => {
expect(widget.attrs.backgroundColor).toBe('#FFFF00');
expect(rows[2].content!.length).toBe(1);
expect(rows[1].attrs.rowHeight).toBe(40);
// per-side borders (w:tcBorders): hidden top + custom red right; defaults stay null
const qty = rows[1].content![1];
expect(qty.attrs.borderTop).toBe('none');
expect(qty.attrs.borderRight).toBe('2.25pt solid #FF0000');
expect(qty.attrs.borderBottom ?? null).toBe(null);
expect(widget.attrs.borderTop ?? null).toBe(null);
});
it('round-trips page geometry + header/footer with page fields', () => {
+6 -1
View File
@@ -76,7 +76,7 @@ const fixture: N = {
] },
{ type: 'tableRow', content: [
{ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: [120] }, content: [P(null, T('A2'))] },
{ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: [240] }, content: [P(null, T('B2'))] },
{ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: [240], borderTop: 'none', borderRight: '2.25pt solid #FF0000' }, content: [P(null, T('B2'))] },
] },
] },
TBX({ width: 288, height: 96, fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1 },
@@ -114,6 +114,11 @@ function normalize(node: N): N {
if (k === 'rotation') { attrs.rotation = 'R'; continue; } // exact angle checked leniently below
if (k === 'wrap') { attrs.wrap = 'W'; continue; } // float survives; exact mode checked leniently
if (k === 'strokeWidthPt') { attrs.strokeWidthPt = Math.round((v as number) * 4) / 4; continue; } // pt↔in noise
// Cell borders: LO re-saves widths with pt↔cm noise; quantize to 0.25pt steps.
if (k.startsWith('border') && typeof v === 'string' && v !== 'none') {
const bm = /^([\d.]+)pt solid (#[0-9A-F]{6})$/i.exec(v);
if (bm) { attrs[k] = `${Math.round(parseFloat(bm[1]) * 4) / 4}pt solid ${bm[2].toUpperCase()}`; continue; }
}
if (k === 'fillColor' || k === 'strokeColor') { attrs[k] = typeof v === 'string' ? v.toUpperCase() : v; continue; }
// LO re-parents text-box paragraphs onto its Frame-contents style (margin 0), so
// an explicit spaceAfter 0 comes back where Standard's default was suppressed.
+35
View File
@@ -328,6 +328,41 @@ describe('Leg 1c: header-row cells (bold-by-default, editable)', () => {
});
});
describe('Leg 1d: table cell borders (per-side, fo:border-*)', () => {
const bcell = (text: string, borders: Record<string, string> = {}): N =>
({ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: [100], ...borders }, content: [P(null, T(text))] });
// A: default borders (null attrs) · B: top hidden + custom red right ·
// C/D: fully borderless.
const doc: N = { type: 'doc', content: [
{ type: 'table', content: [
ROW(bcell('A'), bcell('B', { borderTop: 'none', borderRight: '2.25pt solid #FF0000' })),
ROW(bcell('C', { borderTop: 'none', borderRight: 'none', borderBottom: 'none', borderLeft: 'none' }),
bcell('D', { borderTop: 'none', borderRight: 'none', borderBottom: 'none', borderLeft: 'none' })),
] },
] };
it('exports fo:border-* per side and re-imports the attrs', async () => {
const bytes = await buildOdt(doc, margins, 'portrait');
const xml = strFromU8(unzipSync(bytes)['content.xml']);
check('export emits fo:border-top="none"', xml.includes('fo:border-top="none"'), xml.match(/fo:border-top="[^"]*"/g));
check('export emits the custom red right border', xml.includes('fo:border-right="2.25pt solid #FF0000"'), xml.match(/fo:border-right="[^"]*"/g));
const res = importOdt(bytes);
check('no warnings on own export', res.warnings.length === 0, res.warnings);
const table = (res.content.content ?? []).find((n: N) => n.type === 'table');
const [row0, row1] = table?.content ?? [];
const a = row0?.content?.[0]?.attrs ?? {};
const b = row0?.content?.[1]?.attrs ?? {};
const c = row1?.content?.[0]?.attrs ?? {};
check('A keeps default borders (attrs null)', a.borderTop == null && a.borderRight == null && a.borderBottom == null && a.borderLeft == null, a);
check('B top border stays hidden', b.borderTop === 'none', b);
check('B custom right border round-trips', b.borderRight === '2.25pt solid #FF0000', b);
check('B bottom/left stay default', b.borderBottom == null && b.borderLeft == null, b);
check('C is fully borderless', c.borderTop === 'none' && c.borderRight === 'none' && c.borderBottom === 'none' && c.borderLeft === 'none', c);
});
});
describe('Leg 2: foreign (LibreOffice/Word-style) .odt → importOdt', () => {
it('resolves named/automatic styles, repeated cells, lists, and reports degradations', () => {
const stylesXml = `<?xml version="1.0" encoding="UTF-8"?>
+245
View File
@@ -0,0 +1,245 @@
// Unit tests for the table border feature: the setTableBorders preset command
// (driven against a ProseMirror EditorState, like table-split.test.ts) and the
// attr-value parse/normalize helpers used by export and import.
import { describe, it, expect } from 'vitest';
import { getSchema } from '@tiptap/core';
import { EditorState, TextSelection } from '@tiptap/pm/state';
import { CellSelection } from '@tiptap/pm/tables';
import type { Node as PMNode } from '@tiptap/pm/model';
import Document from '@tiptap/extension-document';
import Paragraph from '@tiptap/extension-paragraph';
import Text from '@tiptap/extension-text';
import { Table, TableHeader, TableCell } from '@tiptap/extension-table';
import { ResizableTableRow } from '../../src/lib/editor/extensions/tableRow';
import {
TableCellBorders, setTableBorders, activeBorderPresets, borderAttrValue, parseBorderAttr,
type BorderPreset, type BorderSpec,
} from '../../src/lib/editor/extensions/tableCellBorders';
import { borderAttrFromOdf } from '../../src/lib/import/odt';
type N = any;
const schema = getSchema([
Document, Paragraph, Text, Table, ResizableTableRow, TableCell, TableHeader, TableCellBorders,
]);
const cell = (text: string): N => ({
type: 'tableCell',
attrs: { colspan: 1, rowspan: 1, colwidth: null },
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
});
const row = (...cells: N[]): N => ({ type: 'tableRow', content: cells });
// 3×3 grid: [A B C] / [D E F] / [G H I]
const grid3 = (): N => ({
type: 'doc',
content: [{ type: 'table', content: [
row(cell('A'), cell('B'), cell('C')),
row(cell('D'), cell('E'), cell('F')),
row(cell('G'), cell('H'), cell('I')),
] }],
});
function textPos(doc: PMNode, text: string): number {
let pos = -1;
doc.descendants((node: PMNode, p: number) => {
if (node.isText && node.text === text) pos = p;
});
if (pos < 0) throw new Error(`text ${text} not found`);
return pos;
}
function cellPos(doc: PMNode, text: string): number {
const $pos = doc.resolve(textPos(doc, text));
for (let d = $pos.depth; d > 0; d--) {
if ($pos.node(d).type.spec.tableRole === 'cell') return $pos.before(d);
}
throw new Error(`no cell around ${text}`);
}
// Run the command with the cursor in `target` (string) or a CellSelection over
// [anchor, head] cells; returns cellText → border attrs for the whole table.
function run(json: N, target: string | [string, string], preset: BorderPreset, spec: BorderSpec): Record<string, N> {
const doc = schema.nodeFromJSON(json);
const selection = typeof target === 'string'
? TextSelection.create(doc, textPos(doc, target))
: new CellSelection(doc.resolve(cellPos(doc, target[0])), doc.resolve(cellPos(doc, target[1])));
const state = EditorState.create({ schema, doc, selection });
let next: EditorState | null = null;
const ok = setTableBorders(preset, spec)({
state,
tr: state.tr,
dispatch: (tr: any) => { next = state.apply(tr); },
} as any);
expect(ok).toBe(true);
const out: Record<string, N> = {};
next!.doc.descendants((node: PMNode) => {
if (node.type.spec.tableRole === 'cell') {
const { borderTop, borderRight, borderBottom, borderLeft } = node.attrs;
out[node.textContent] = { borderTop, borderRight, borderBottom, borderLeft };
}
});
return out;
}
const RED = { widthPt: 1, color: '#FF0000' };
const V = '1pt solid #FF0000';
describe('setTableBorders', () => {
it('"all" on a single cell sets its four sides and mirrors the facing neighbour sides', () => {
const t = run(grid3(), 'E', 'all', RED);
expect(t.E).toEqual({ borderTop: V, borderRight: V, borderBottom: V, borderLeft: V });
expect(t.B.borderBottom).toBe(V); // above
expect(t.H.borderTop).toBe(V); // below
expect(t.D.borderRight).toBe(V); // left
expect(t.F.borderLeft).toBe(V); // right
// Corners untouched.
expect(t.A).toEqual({ borderTop: null, borderRight: null, borderBottom: null, borderLeft: null });
// Neighbours' other sides untouched.
expect(t.B.borderTop).toBe(null);
expect(t.D.borderLeft).toBe(null);
});
it('"all" with the default 0.5pt black spec normalizes to null (table default)', () => {
const t = run(grid3(), 'E', 'all', { widthPt: 0.5, color: '#000000' });
expect(t.E).toEqual({ borderTop: null, borderRight: null, borderBottom: null, borderLeft: null });
});
it('a null spec removes borders ("no border"), both sides of each boundary', () => {
const t = run(grid3(), 'E', 'all', null);
expect(t.E).toEqual({ borderTop: 'none', borderRight: 'none', borderBottom: 'none', borderLeft: 'none' });
expect(t.B.borderBottom).toBe('none');
expect(t.F.borderLeft).toBe('none');
});
it('"outer" on a 2×2 region sets only the region edges (+ outside mirrors)', () => {
const t = run(grid3(), ['A', 'E'], 'outer', RED); // region = A B / D E
expect(t.A.borderTop).toBe(V);
expect(t.A.borderLeft).toBe(V);
expect(t.B.borderTop).toBe(V);
expect(t.B.borderRight).toBe(V);
expect(t.E.borderRight).toBe(V);
expect(t.E.borderBottom).toBe(V);
// Inner boundaries untouched.
expect(t.A.borderRight).toBe(null);
expect(t.A.borderBottom).toBe(null);
expect(t.E.borderTop).toBe(null);
expect(t.E.borderLeft).toBe(null);
// Outside neighbours mirror the shared edge.
expect(t.C.borderLeft).toBe(V);
expect(t.F.borderLeft).toBe(V);
expect(t.G.borderTop).toBe(V);
expect(t.H.borderTop).toBe(V);
expect(t.I).toEqual({ borderTop: null, borderRight: null, borderBottom: null, borderLeft: null });
});
it('"inner" on a 2×2 region sets only the boundaries between region cells', () => {
const t = run(grid3(), ['A', 'E'], 'inner', RED);
expect(t.A.borderRight).toBe(V);
expect(t.B.borderLeft).toBe(V);
expect(t.A.borderBottom).toBe(V);
expect(t.D.borderTop).toBe(V);
expect(t.E.borderTop).toBe(V);
expect(t.E.borderLeft).toBe(V);
// Region edges untouched.
expect(t.A.borderTop).toBe(null);
expect(t.A.borderLeft).toBe(null);
expect(t.E.borderRight).toBe(null);
expect(t.E.borderBottom).toBe(null);
expect(t.C.borderLeft).toBe(null);
});
it('"top" sets only the region top edge', () => {
const t = run(grid3(), ['D', 'E'], 'top', RED); // region = D E (row 1)
expect(t.D.borderTop).toBe(V);
expect(t.E.borderTop).toBe(V);
expect(t.A.borderBottom).toBe(V); // mirror above
expect(t.B.borderBottom).toBe(V);
expect(t.D.borderBottom).toBe(null);
expect(t.D.borderLeft).toBe(null);
expect(t.F.borderTop).toBe(null);
});
});
describe('activeBorderPresets', () => {
function stateFor(json: N, target: string | [string, string]): EditorState {
const doc = schema.nodeFromJSON(json);
const selection = typeof target === 'string'
? TextSelection.create(doc, textPos(doc, target))
: new CellSelection(doc.resolve(cellPos(doc, target[0])), doc.resolve(cellPos(doc, target[1])));
return EditorState.create({ schema, doc, selection });
}
const THIN = { widthPt: 0.5, color: '#000000' };
it('fresh table (default borders) + default pen → every preset active except none', () => {
const a = activeBorderPresets(stateFor(grid3(), ['A', 'I']), THIN)!;
expect(a.all && a.outer && a.inner && a.innerH && a.innerV && a.top && a.bottom && a.left && a.right).toBe(true);
expect(a.none).toBe(false);
});
it('active state follows the pen width: thicker pen → default borders no longer match', () => {
const a = activeBorderPresets(stateFor(grid3(), ['A', 'I']), { widthPt: 2.25, color: '#000000' })!;
expect(a.all).toBe(false);
expect(a.outer).toBe(false);
expect(a.none).toBe(false);
});
it('borderless region → only none active', () => {
// Remove all borders of the whole table first.
const st = stateFor(grid3(), ['A', 'I']);
let next: EditorState | null = null;
setTableBorders('all', null)({ state: st, tr: st.tr, dispatch: (tr: any) => { next = st.apply(tr); } } as any);
const a = activeBorderPresets(next!, THIN)!;
expect(a.none).toBe(true);
expect(a.all).toBe(false);
expect(a.top).toBe(false);
});
it('single cell: inner presets have no boundaries → inactive; edges active', () => {
const a = activeBorderPresets(stateFor(grid3(), 'E'), THIN)!;
expect(a.inner).toBe(false);
expect(a.innerH).toBe(false);
expect(a.all && a.outer && a.top && a.bottom && a.left && a.right).toBe(true);
});
it('a boundary with a mismatched color breaks the match for presets that cover it', () => {
const st = stateFor(grid3(), 'E');
let next: EditorState | null = null;
setTableBorders('top', { widthPt: 0.5, color: '#FF0000' })({ state: st, tr: st.tr, dispatch: (tr: any) => { next = st.apply(tr); } } as any);
const withSel = EditorState.create({ schema, doc: next!.doc, selection: TextSelection.create(next!.doc, textPos(next!.doc, 'E')) });
const a = activeBorderPresets(withSel, THIN)!;
expect(a.top).toBe(false); // now red, pen is black
expect(a.all).toBe(false);
expect(a.bottom).toBe(true); // untouched sides still match
const red = activeBorderPresets(withSel, { widthPt: 0.5, color: '#FF0000' })!;
expect(red.top).toBe(true);
});
it('returns null outside a table', () => {
const doc = schema.nodeFromJSON({ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }] });
const st = EditorState.create({ schema, doc, selection: TextSelection.create(doc, 1) });
expect(activeBorderPresets(st, THIN)).toBe(null);
});
});
describe('border attr helpers', () => {
it('borderAttrValue: default collapses to null, none to "none", custom to canonical', () => {
expect(borderAttrValue({ widthPt: 0.5, color: '#000000' })).toBe(null);
expect(borderAttrValue(null)).toBe('none');
expect(borderAttrValue({ widthPt: 2.25, color: '#ff0000' })).toBe('2.25pt solid #FF0000');
});
it('parseBorderAttr inverts borderAttrValue', () => {
expect(parseBorderAttr(null)).toBe(null);
expect(parseBorderAttr('none')).toBe('none');
expect(parseBorderAttr('2.25pt solid #FF0000')).toEqual({ widthPt: 2.25, color: '#FF0000' });
});
it('borderAttrFromOdf: absent/none → "none", the 0.5pt-black default → null (unit tolerance)', () => {
expect(borderAttrFromOdf(undefined)).toBe('none');
expect(borderAttrFromOdf('none')).toBe('none');
expect(borderAttrFromOdf('0.5pt solid #000000')).toBe(null);
expect(borderAttrFromOdf('0.018cm solid #000000')).toBe(null); // LibreOffice cm form
expect(borderAttrFromOdf('2.25pt solid #ff0000')).toBe('2.25pt solid #FF0000');
expect(borderAttrFromOdf('0.06pt double #808080')).toBe('0.06pt solid #808080'); // style coerced
});
});