diff --git a/docs/architecture/tables.md b/docs/architecture/tables.md index 467a1cb..8bdc4df 100644 --- a/docs/architecture/tables.md +++ b/docs/architecture/tables.md @@ -15,7 +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`. +- **`tableCellAlign.ts`** (`TableCellAlign`) — `verticalAlign` (`middle`/`bottom`, null = top, the ODF and Word default) on `tableCell`/`tableHeader`, rendered as the cell's CSS `vertical-align`. A callout table's label sits mid-cell with it, at the row's top without. Round-trips to ODF `style:vertical-align` (import via `StyleResolver.cellVerticalAlign`, export via odf-kit's `CellOptions.verticalAlign` — it mints one cell style per property set, so a differing cell gets its own) and DOCX `w:vAlign` (Word's `center` is our `middle`; `top`/`both` stay the default). - **`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/export/docx.ts b/src/lib/export/docx.ts index 8f7f8b3..42bdf9e 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -927,6 +927,9 @@ function tableToDocx(node: TiptapNode, contentWidthCm: number, num: Numbering): ? { marginUnitType: WidthType.DXA, top: cmToTwip(ownPad[0]), right: cmToTwip(ownPad[1]), bottom: cmToTwip(ownPad[2]), left: cmToTwip(ownPad[3]) } : undefined, shading: fill ? { type: ShadingType.CLEAR, fill } : undefined, + // Content set against the middle/bottom of the box → w:vAlign. + verticalAlign: cell.attrs?.verticalAlign === 'middle' ? 'center' + : cell.attrs?.verticalAlign === 'bottom' ? 'bottom' : undefined, width: widthTwip ? { size: widthTwip, type: WidthType.DXA } : undefined, borders: { top: docxCellBorder(cell.attrs, 'borderTop'), diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 550d2eb..ad22365 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -2669,6 +2669,9 @@ function exportTable(node: TiptapNode, doc: OdtDocument, contentWidthCm: number, const c = normalizeColor(bg); if (c) opts.backgroundColor = c; } + // Content set against the middle/bottom of the box → style:vertical-align. + const va = cell.attrs?.verticalAlign; + if (va === 'middle' || va === 'bottom') opts.verticalAlign = va; // 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); diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 930fb8e..108677e 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -1673,6 +1673,11 @@ function buildTable(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; + // w:vAlign — Word's "center" is the editor's "middle"; "top"/"both" stay the default. + const vAlignEl = fc(tcPr, 'vAlign'); + const vAlign = vAlignEl ? wVal(vAlignEl) : null; + if (vAlign === 'center') attrs.verticalAlign = 'middle'; + else if (vAlign === 'bottom') attrs.verticalAlign = 'bottom'; // The cell's own margins (w:tcMar), kept only where they differ from the table's. const ownPad = cellPaddingAttr(cellMarginsCm([fc(tcPr, 'tcMar')]), padBase); if (ownPad) attrs.cellPadding = ownPad; diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 776a72f..75e91db 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -507,6 +507,7 @@ describe('Leg 1b: merged table cells (colspan/rowspan)', () => { ] }, ], }; + mergedDoc.content![1].content![0].content![1].attrs!.verticalAlign = 'middle'; it('exports spans + covered cells and re-imports them', async () => { const bytes = await buildOdt(mergedDoc, margins, 'portrait'); @@ -527,6 +528,8 @@ describe('Leg 1b: merged table cells (colspan/rowspan)', () => { check('C has rowspan 2', rows[1]?.content?.[0]?.attrs?.rowspan === 2, rows[1]?.content?.[0]?.attrs); check('A shading round-trips (#FFFF00)', rows[0]?.content?.[0]?.attrs?.backgroundColor === '#FFFF00', rows[0]?.content?.[0]?.attrs?.backgroundColor); check('B has no shading', rows[0]?.content?.[1]?.attrs?.backgroundColor == null, rows[0]?.content?.[1]?.attrs?.backgroundColor); + check('B keeps vertical-align middle', rows[0]?.content?.[1]?.attrs?.verticalAlign === 'middle', rows[0]?.content?.[1]?.attrs?.verticalAlign); + check('A stays top-aligned', rows[0]?.content?.[0]?.attrs?.verticalAlign == null, rows[0]?.content?.[0]?.attrs?.verticalAlign); const textOf = (cell: N) => cell?.content?.[0]?.content?.[0]?.text; check('A text preserved', textOf(rows[0]?.content?.[0]) === 'A', textOf(rows[0]?.content?.[0])); diff --git a/tests/unit/cell-valign.test.ts b/tests/unit/cell-valign.test.ts new file mode 100644 index 0000000..27d4350 --- /dev/null +++ b/tests/unit/cell-valign.test.ts @@ -0,0 +1,30 @@ +// A cell's verticalAlign attr round-trips through DOCX (w:vAlign); the ODF leg +// (style:vertical-align) rides along in roundtrip.test.ts. +import { describe, it, expect } from 'vitest'; +import { buildDocx } from '../../src/lib/export/docx'; +import { importDocx } from '../../src/lib/import/docx'; + +type N = any; + +const cell = (text: string, verticalAlign?: string): N => ({ + type: 'tableCell', + attrs: { colspan: 1, rowspan: 1, colwidth: [120], ...(verticalAlign ? { verticalAlign } : {}) }, + content: [{ type: 'paragraph', content: [{ type: 'text', text }] }], +}); + +describe('table cell vertical alignment', () => { + it('round-trips middle/bottom and leaves top unset', async () => { + const doc: N = { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'Before' }] }, + { type: 'table', content: [ + { type: 'tableRow', content: [cell('A', 'middle'), cell('B', 'bottom'), cell('C')] }, + ] }, + ], + }; + const res = importDocx(await buildDocx(doc, { top: 2, bottom: 2, left: 2, right: 2 }, 'portrait')); + const cells = (res.content.content!.find((n: N) => n.type === 'table') as N).content[0].content; + expect(cells.map((c: N) => c.attrs?.verticalAlign ?? null)).toEqual(['middle', 'bottom', null]); + }); +});