diff --git a/CHANGELOG.md b/CHANGELOG.md
index c35373c..3a44ec6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/src/lib/components/TableBorderPicker.svelte b/src/lib/components/TableBorderPicker.svelte
new file mode 100644
index 0000000..61cf660
--- /dev/null
+++ b/src/lib/components/TableBorderPicker.svelte
@@ -0,0 +1,310 @@
+
+
+
+
(open = !open)}
+ >
+
+
+
+
+
+
+
+
+
+ {#if open}
+
+
Borders
+
+ {#each PRESETS as preset}
+
apply(preset.id)}
+ >
+ {#if preset.id === 'none'}
+
+
+ {#each ALL_SEGS as seg}
+
+ {/each}
+
+
+
+ {:else}
+
+ {#each ALL_SEGS as seg}
+ {#if !preset.on.includes(seg)}
+
+ {/if}
+ {/each}
+ {#each preset.on as seg}
+
+ {/each}
+
+ {/if}
+
+ {/each}
+
+
+
Line width
+
+ {#each WIDTHS as w}
+ (widthPt = w)}
+ >
+
+
+ {/each}
+
+
+
Line color
+
+ {#each COLORS as row}
+
+ {#each row as c}
+ (color = c)}
+ >
+ {/each}
+
+ {/each}
+
+
+ {/if}
+
+
+
diff --git a/src/lib/components/TableToolbar.svelte b/src/lib/components/TableToolbar.svelte
index ac1353b..b34d918 100644
--- a/src/lib/components/TableToolbar.svelte
+++ b/src/lib/components/TableToolbar.svelte
@@ -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}
+
+
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 {
+ 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 = {
+ 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,
+ 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>();
+ 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,
+): Record | 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;
+ 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) => {
+ 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),
+ };
+ },
+});
diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts
index 3eb8aa1..adb9716 100644
--- a/src/lib/export/docx.ts
+++ b/src/lib/export/docx.ts
@@ -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 | 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),
}));
}
diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts
index 8ce2b84..6ac919c 100644
--- a/src/lib/export/odt.ts
+++ b/src/lib/export/odt.ts
@@ -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;
diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts
index 94810b3..7d7da2f 100644
--- a/src/lib/import/docx.ts
+++ b/src/lib/import/docx.ts
@@ -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 '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 = { 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);
diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts
index 53e07ff..1064147 100644
--- a/src/lib/import/odt.ts
+++ b/src/lib/import/odt.ts
@@ -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 '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 = {};
+ 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 = { colspan, rowspan };
+ const attrs: Record = { colspan, rowspan, ...borders };
if (backgroundColor) attrs.backgroundColor = backgroundColor;
if (weights) attrs.colwidth = weights.slice(colIndex, colIndex + colspan);
cells.push({
diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts
index 7b79dea..2340bb9 100644
--- a/src/lib/import/styleResolver.ts
+++ b/src/lib/import/styleResolver.ts
@@ -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> {
+ 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> = {};
+ 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;
}
diff --git a/tests/docx-roundtrip.test.ts b/tests/docx-roundtrip.test.ts
index e1aa5de..92668af 100644
--- a/tests/docx-roundtrip.test.ts
+++ b/tests/docx-roundtrip.test.ts
@@ -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', () => {
diff --git a/tests/lo-roundtrip.test.ts b/tests/lo-roundtrip.test.ts
index cd1ebee..c148d24 100644
--- a/tests/lo-roundtrip.test.ts
+++ b/tests/lo-roundtrip.test.ts
@@ -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.
diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts
index cb0b99e..f6efb09 100644
--- a/tests/roundtrip.test.ts
+++ b/tests/roundtrip.test.ts
@@ -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 = {}): 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 = `
diff --git a/tests/unit/table-borders.test.ts b/tests/unit/table-borders.test.ts
new file mode 100644
index 0000000..0df6a5f
--- /dev/null
+++ b/tests/unit/table-borders.test.ts
@@ -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 {
+ 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 = {};
+ 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
+ });
+});