From a021bc42aa5c5d44cf811a53e184d9052ddda9c6 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Mon, 10 Aug 2026 11:03:34 +0000 Subject: [PATCH] feat: a cell's content sits where its style puts it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ODF style:vertical-align (middle/bottom) on a table cell, which every LibreOffice callout table uses for its Tip/Note label. Import and render only: odf-kit dedupes cell styles by property, so writing it back needs a pass of its own — noted in docs/architecture/tables.md. --- docs/architecture/tables.md | 1 + src/lib/editor/extensions.ts | 3 ++ src/lib/editor/extensions/tableCellAlign.ts | 32 +++++++++++++++++++++ src/lib/import/odt.ts | 2 ++ src/lib/import/styleResolver.ts | 7 +++++ 5 files changed, 45 insertions(+) create mode 100644 src/lib/editor/extensions/tableCellAlign.ts diff --git a/docs/architecture/tables.md b/docs/architecture/tables.md index ebc7aae..467a1cb 100644 --- a/docs/architecture/tables.md +++ b/docs/architecture/tables.md @@ -15,6 +15,7 @@ Covers the `table*.ts` extensions in `src/lib/editor/extensions/` and the table- - **`tableCellPadding.ts`** (`TableCellPadding`) — **cell margins**, a global `cellPadding` attr `[top, right, bottom, left]` in cm on `table` *and* on `tableCell`/`tableHeader`. `null` = inherit: a table's from the default `[0, 0.19, 0, 0.19]` (Word's own, and `editor.css`'s fallback), a cell's from its table — so `cellPaddingAttr(sides, base)` takes the baseline it is measured against, and only a real disagreement carries an attr. A side within `SAME_CM` (0.005cm) of that baseline *is* the baseline, or Word's 108 twips (0.1905cm) and LibreOffice's 0.191cm would tag every table. Rendered as one `--cell-pad` custom property (the attr's `renderHTML` for the static paths, `TableView` for the node view, since it bypasses `renderHTML`) that `td/th { padding: var(--cell-pad, 0 0.19cm) }` reads — the property inherits, so a cell's own declaration wins for its own `td`. **DOCX**: `w:tblPr/w:tblCellMar` direct, else the table style's (`DocxStyles.tableCellMar` walks `w:basedOn`), and `w:tcPr/w:tcMar` per cell (`cellMarginsCm` reads either, `w:type="nil"` being an explicit zero); export writes `Table.margins` + `TableCell.margins`. **ODF** has no table-level cell margin — it sits on each cell's style, so import takes the first cell's `fo:padding*` for the table and measures the rest against it, and export rides each cell's value on it as a CSS-style TRBL shorthand that `expandCellPadding` splits per side (odf-kit only writes the `fo:padding` shorthand, and ODF allows it only one length). Getting this wrong is not cosmetic: a 0.71cm bibliography marker column lost 0.38cm to the old constant and wrapped `"[2] "` onto three lines. - **`tableHeaderRow.ts`** (`TableHeaderRow`) — "header row" as a **styling preset** (not the structural ODF header that repeats per page, by design): `toggleHeaderRowStyle` sets/clears a **light-grey fill (`HEADER_SHADE` `#F2F2F2`)** on the first row's cells; `isHeaderStyled(state, axis)` drives the toolbar toggle's active state. **A table carrying a table style owns these two areas through its Table Style Options instead**: there `toggleHeaderStyle`/`isHeaderStyled` read and write `headerRow`/`firstColumn` in the table's `tableLook` (delegating to `setTableLook`), so the toolbar button and the gallery's checkbox are one state and can't disagree. Both also read their label from the same key (`styles.regions.*`), so they can't drift apart in wording either — the button used to say "header column" where the checkbox said "first column". The fill is the *marker* and **round-trips natively** as `fo:background-color`, so the toggle state survives reopen with no `table:table-header-rows` (⇒ Word/LibreOffice don't repeat it). **Bold is presentational, exactly like a heading**: `tableCellBackground.ts` tags a header-shaded cell with `class="cell-header"` and `editor.css` renders `.cell-header` bold — so *all* its text is bold (existing, typed, pasted, IME) without storing marks, and it stays **editable** via the `fontWeight:'normal'` override (the `Toolbar.svelte` Bold button treats `isInHeaderCell` like a heading: toggles `setFontWeight('normal')`/`unsetFontWeight`). Export (`export/odt.ts`) **bakes** bold onto header-shaded cells' runs (`applyRuns(..., forceBold)`, skipping `fontWeight:'normal'` runs) so Word/LibreOffice match; import (`import/odt.ts`) converts those cells' runs with `boldByDefault` (bold run → no mark, normal run → `fontWeight:'normal'`), mirroring headings. `TableToolbar.svelte` exposes the toggle button. +- **`tableCellAlign.ts`** (`TableCellAlign`) — `verticalAlign` (`middle`/`bottom`, null = top, the ODF and Word default) on `tableCell`/`tableHeader`, rendered as the cell's CSS `vertical-align`. Read on import from `style:vertical-align` (`StyleResolver.cellVerticalAlign`); a callout table's label sits mid-cell with it, at the row's top without. **Import only so far** — odf-kit dedupes its minted cell styles by their properties, so exporting it needs a pass that mints a variant style per cell and rewrites that cell's `table:style-name`. - **`tableStyle.ts`** (`TableStyle`) — the **table style** family's editor half (model in `styles/tableStyles.ts`, see below): global attrs `tableStyle` on `table` (⇄ `data-table-style`) and `region` on `tableCell`/`tableHeader` (⇄ `data-region`, a space-separated region list). `setTableStyle(name|null)` writes the attr and **materializes** the style into the cells in one transaction — `paintTable` walks the `TableMap`, resolves each cell via `resolveTableCell` (merged cells by their top-left `findCell` box) and writes `backgroundColor`/the four border attrs/`region`, so **export, PDF and clipboard need no new code**. It only writes cells that differ, which makes it idempotent — the condition the re-band plugin depends on. That plugin (`appendTransaction`) repaints a styled table when its row/column **shape** changes (Word's re-banding); deliberately *not* on every transaction, or a manually shaded cell would be repainted away immediately. `refreshTableStyles()` repaints every styled table and is called from `Editor.svelte`'s document-stylesheet `$effect` — the choke point every registry change (edit, rename, reset, import) passes through. `activeTableStyle`/`cellRegionText` feed the gallery and the Bold button (a style's bold region behaves like a heading, see `tableHeaderRow.ts`). `TableView` sets `data-table-style` itself, since the node view bypasses `renderHTML`. We deliberately keep TipTap's `resizable: false` (so its own columnResizing plugin isn't loaded) and supply the custom view + drag plugins instead. diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index c4bda53..0d43fcf 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -41,6 +41,7 @@ import { TableColumnResize } from './extensions/tableColumnResize'; import { TableRowResize } from './extensions/tableRowResize'; import { TableSplit } from './extensions/tableSplit'; import { TableCellBackground } from './extensions/tableCellBackground'; +import { TableCellAlign } from './extensions/tableCellAlign'; import { TableCellBorders } from './extensions/tableCellBorders'; import { TableCellPadding } from './extensions/tableCellPadding'; import { TableHeaderRow } from './extensions/tableHeaderRow'; @@ -145,6 +146,8 @@ export const extensions = [ TableSplit, // Cell shading: backgroundColor attr on tableCell/tableHeader (→ fo:background-color). TableCellBackground, + // Cell content set against the middle/bottom of its box (→ style:vertical-align). + TableCellAlign, // Border presets: per-side border attrs on cells (→ fo:border-*). TableCellBorders, // The table's cell margins (→ w:tblCellMar / fo:padding on the cell style). diff --git a/src/lib/editor/extensions/tableCellAlign.ts b/src/lib/editor/extensions/tableCellAlign.ts new file mode 100644 index 0000000..f64fc3b --- /dev/null +++ b/src/lib/editor/extensions/tableCellAlign.ts @@ -0,0 +1,32 @@ +import { Extension } from '@tiptap/core'; + +// Where a cell's content sits in its box: a `verticalAlign` attr on table cells, +// rendered as the cell's CSS vertical-align. null = top, which is what both +// LibreOffice (style:vertical-align="top") and Word (no w:vAlign) default to. + +export type CellVerticalAlign = 'middle' | 'bottom'; + +export const TableCellAlign = Extension.create({ + name: 'tableCellAlign', + + addGlobalAttributes() { + return [ + { + types: ['tableCell', 'tableHeader'], + attributes: { + verticalAlign: { + default: null, + parseHTML: (element: HTMLElement) => { + const v = element.style.verticalAlign; + return v === 'middle' || v === 'bottom' ? v : null; + }, + renderHTML: (attributes: Record) => { + const v = attributes.verticalAlign; + return v === 'middle' || v === 'bottom' ? { style: `vertical-align: ${v}` } : {}; + }, + }, + }, + }, + ]; + }, +}); diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 1b4f39b..47858d5 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -1894,6 +1894,7 @@ function convertTable(el: Element, ctx: Ctx): Node | null { const padSides = ctx.resolver.cellPadding(cellStyleName); if (cellPad === undefined) cellPad = cellPaddingAttr(padSides); const ownPad = cellPaddingAttr(padSides, cellPad ?? DEFAULT_CELL_PADDING); + const verticalAlign = ctx.resolver.cellVerticalAlign(cellStyleName); 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'. @@ -1913,6 +1914,7 @@ function convertTable(el: Element, ctx: Ctx): Node | null { const attrs: Record = { colspan, rowspan, ...borders }; if (ownPad) attrs.cellPadding = ownPad; if (backgroundColor) attrs.backgroundColor = backgroundColor; + if (verticalAlign) attrs.verticalAlign = verticalAlign; if (weights) attrs.colwidth = weights.slice(colIndex, colIndex + colspan); cells.push({ type: header ? 'tableHeader' : 'tableCell', diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index 01c1dfe..eec8f49 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -384,6 +384,13 @@ export class StyleResolver { return c && c !== 'transparent' && c !== 'none' ? c : null; } + // A cell style's style:vertical-align, where it is not the ODF default (top). + cellVerticalAlign(styleName: string | null): 'middle' | 'bottom' | null { + if (!styleName) return null; + const v = this.merged('table-cell', styleName).misc['style:vertical-align']; + return v === 'middle' ? 'middle' : v === 'bottom' ? 'bottom' : 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> {