Two ways a table's height was off. Its space above was a margin, so it collapsed against the paragraph above instead of adding to it — now it rides --space-before like a paragraph's. And every cell border paints a whole pixel however thin the file declares it, where LibreOffice reserves the declared width; the excess now comes off the cell's padding-bottom, 0.25mm a row.
19 KiB
Tables
Covers the table*.ts extensions in src/lib/editor/extensions/ and the table-style family
(src/lib/styles/tableStyles.ts, components/TableStylePicker.svelte).
Table extensions
-
tableView.ts(TableView) — custom table node view. Unlike TipTap's built-in view it renders the<colgroup>with percentage widths and keeps the table atwidth:100%, so the table fills its wrapper (by default the full text width, matching the ODT export) and stays responsive to margin/orientation changes. The per-columncolwidthcell attrs are treated as proportional weights (only ratios matter;null= equal share). The table's ownmarginLeft/marginRightattrs (cm, from the outer-edge drag) go on the.tableWrapper— that's what narrows the table. -
tableRow.ts(ResizableTableRow) —TableRowextended with arowHeightattr (unscaled doc px @96dpi), rendered as a CSS min-height (heighton<tr>), exported asstyle:min-row-height.Table(unlikeTableKit) does NOT auto-add child extensions, soResizableTableRowmust be listed explicitly. -
tableColumnResize.ts(TableColumnResize) — Word-style column drag: dragging an inner border trades width between two columns only (their sum stays constant, table width fixed). Live feedback pokes the<col>elements; release writes new weights intocolwidth. The table's outer left/right edges are draggable too (Word/LibreOffice): they move that edge alone — the pureedgeResizehelper shifts the globalmarginLeft/marginRightattrs it adds ontable(cm,>= 0, so the table never leaves the text area) and resizes only the adjacent column, so every other gridline stays put; the minimum isCELL_MIN_PX.borderAtclassifies a hovered border asinner/left/right, the handle decoration mirrors to the cell's left side (.column-resize-handle.left), and the commit writescolwidth+ the table attrs in one transaction.TableViewrenders the margins on the wrapper; the attrs' ownrenderHTMLemits them (plus a matchingwidth: calc(...)) for the static HTML paths (PDF export, clipboard). They round-trip as ODFfo:margin-left/-right+style:width(applyTableMargins) and DOCXw:tblInd+ the narrowerw:tblGrid. -
tableRowResize.ts(TableRowResize) — Word-style row-height drag (changes the row above the grid line; min-height semantics). Yields to column resize at a corner. -
tableSplit.ts(TableSplit) — Word/LibreOffice "Split Cells…": addssplitCellInto(cols, rows)(prosemirror-tables only shipssplitCell= un-merge). Reads the grid viaTableMap, expands it (duplicating the cell region's right-edge column / bottom-edge row so every other cell crossing a new grid line grows its span — the bridging Word does), partitions the region intocols×rowssub-cells (first keeps the original content), recomputescolwidthweights, and rebuilds the table node in one step. Merge uses extension-table's built-inmergeCells(drag/shift-clickCellSelectionvia the always-ontableEditingplugin). Both are surfaced byTableToolbar.svelte(Merge button + Split button →TableSplitDialog.svelte). -
tableCellBackground.ts(TableCellBackground) — adds abackgroundColorattr totableCell/tableHeader(rendered as the cell's CSS background), set via extension-table's built-insetCellAttribute(so it applies to a wholeCellSelectionor the cursor's cell).TableToolbar.svelteexposes it via a reusedColorPicker(cell shading); round-trips to ODFfo:background-color(export/import below). Survives split/merge (attrs are carried through). -
tableCellBorders.ts(TableCellBorders) — Word/LibreOffice border control: per-side attrsborderTop/Right/Bottom/LeftontableCell/tableHeader(null= the table default 0.5pt black,'none', or canonical'<W>pt solid #RRGGBB'; rendered as per-side inline CSS — pt→px, min 1px — plusdata-border-*for HTML parse).setTableBorders(preset, spec)applies a preset (all/outer/inner/innerH/innerV/top/bottom/left/right; specnull= no border, the default 0.5pt-black spec normalizes tonull) to the selected cell region (selectedRect), writing both sides of every affected boundary — including the facing side of neighbour cells outside the region — so collapsed borders never disagree between adjacent cells. Surfaced byTableBorderPicker.svelte(dropdown inTableToolbar.svelte: preset grid + line width + line color; presets apply with the picker's current pen settings). Word-like button states:activeBorderPresets(state, pen)marks a preset active when every boundary it targets renders exactly the pen border — width and color, where a boundary's effective border is the collapse winner of its two facing cell sides and attrnullcounts as the 0.5pt-black default — so a fresh table lights the presets up under the default pen and a thicker pen turns them off;'none'is active when the region is borderless. Clicking an active preset toggles those borders off; an inactive one applies the pen. The panel stays open on preset clicks so the states visibly update. Round-trips to ODFfo:border-*(export → odf-kitCellOptions.border*,nullfalls back to the table's default border; import viaStyleResolver.cellBorders+borderAttrFromOdf— an undeclared side ⇒'none', the 0.5pt-black default ⇒nullwith unit tolerance, non-solid styles coerced to solid) and DOCXw:tcBorders(import also resolves table-levelw:tblBordersby cell position; sides nothing declares staynull= default). -
tableCellPadding.ts(TableCellPadding) — cell margins, a globalcellPaddingattr[top, right, bottom, left]in cm ontableand ontableCell/tableHeader.null= inherit: a table's from the default[0, 0.19, 0, 0.19](Word's own, andeditor.css's fallback), a cell's from its table — socellPaddingAttr(sides, base)takes the baseline it is measured against, and only a real disagreement carries an attr. A side withinSAME_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-padcustom property (the attr'srenderHTMLfor the static paths,TableViewfor the node view, since it bypassesrenderHTML) thattd/th { padding: var(--cell-pad, 0 0.19cm) }reads — the property inherits, so a cell's own declaration wins for its owntd. DOCX:w:tblPr/w:tblCellMardirect, else the table style's (DocxStyles.tableCellMarwalksw:basedOn), andw:tcPr/w:tcMarper cell (cellMarginsCmreads either,w:type="nil"being an explicit zero); export writesTable.margins+TableCell.margins. ODF has no table-level cell margin — it sits on each cell's style, so import takes the first cell'sfo: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 thatexpandCellPaddingsplits per side (odf-kit only writes thefo:paddingshorthand, 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):toggleHeaderRowStylesets/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: theretoggleHeaderStyle/isHeaderStyledread and writeheaderRow/firstColumnin the table'stableLook(delegating tosetTableLook), 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 asfo:background-color, so the toggle state survives reopen with notable:table-header-rows(⇒ Word/LibreOffice don't repeat it). Bold is presentational, exactly like a heading:tableCellBackground.tstags a header-shaded cell withclass="cell-header"andeditor.cssrenders.cell-headerbold — so all its text is bold (existing, typed, pasted, IME) without storing marks, and it stays editable via thefontWeight:'normal'override (theToolbar.svelteBold button treatsisInHeaderCelllike a heading: togglessetFontWeight('normal')/unsetFontWeight). Export (export/odt.ts) bakes bold onto header-shaded cells' runs (applyRuns(..., forceBold), skippingfontWeight:'normal'runs) so Word/LibreOffice match; import (import/odt.ts) converts those cells' runs withboldByDefault(bold run → no mark, normal run →fontWeight:'normal'), mirroring headings.TableToolbar.svelteexposes the toggle button. -
tableCellAlign.ts(TableCellAlign) —verticalAlign(middle/bottom, null = top, the ODF and Word default) ontableCell/tableHeader, rendered as the cell's CSSvertical-align. A callout table's label sits mid-cell with it, at the row's top without. Round-trips to ODFstyle:vertical-align(import viaStyleResolver.cellVerticalAlign, export via odf-kit'sCellOptions.verticalAlign— it mints one cell style per property set, so a differing cell gets its own) and DOCXw:vAlign(Word'scenteris ourmiddle;top/bothstay the default). Set fromTableToolbar.svelte's three buttons viasetCellAttribute, which covers a wholeCellSelection. -
tableStyle.ts(TableStyle) — the table style family's editor half (model instyles/tableStyles.ts, see below): global attrstableStyleontable(⇄data-table-style) andregionontableCell/tableHeader(⇄data-region, a space-separated region list).setTableStyle(name|null)writes the attr and materializes the style into the cells in one transaction —paintTablewalks theTableMap, resolves each cell viaresolveTableCell(merged cells by their top-leftfindCellbox) and writesbackgroundColor/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 fromEditor.svelte's document-stylesheet$effect— the choke point every registry change (edit, rename, reset, import) passes through.activeTableStyle/cellRegionTextfeed the gallery and the Bold button (a style's bold region behaves like a heading, seetableHeaderRow.ts).TableViewsetsdata-table-styleitself, since the node view bypassesrenderHTML.
We deliberately keep TipTap's resizable: false (so its own columnResizing plugin isn't loaded) and supply the custom view + drag plugins instead.
Table styles (third style family)
-
Table styles are the third family (
sheet.table, model instyles/tableStyles.ts— its own shape, and no inheritance, because LibreOffice's table AutoFormats have none, soresolveStyle/styleOrderstay untouched). A style holds an outerborder, aninnerBorder(optionally split intoinnerBorderH/innerBorderV, which is what "row lines only" needs) and per-region props (fill,text, per-sideborders):TABLE_REGIONSlists the conditional areas in Word's ascending precedence (bandedColumn<bandedRow<lastColumn<firstColumn<lastRow<headerRow), withwholeTableas the base.resolveTableCell(style, coords, look)is the single source of truth for all four consumers (apply, CSS, export bake, preview tiles): it layers the matching regions, counts banding over body rows (a header row doesn't shift the stripes) and decides each border per grid line from both sides, so two facing cells never disagree once collapsed. Table Style Options are Word's per-tablew:tblLook: aTableLook(one flag per region) stored space-separated in the table'stableLookattr, absent ⇒DEFAULT_TABLE_LOOK(Word's header row + first column + banded rows). A region paints only when the style defines it and the look enables it — which also decides whether banding skips the header row/first column, so toggling the header shifts every stripe.setTableLook(region, on)flips one and repaints; the look is part ofshapeOf, so the re-band plugin sees it change. Every built-in defines all six areas, so each toggle visibly does something; the gallery's checkboxes grey out the ones the current style doesn't paint, and its tiles preview the table's own look. The colourful families (the four box lists, both accent tables) fill first/last column and the total row with a mid tint between header and band, so switching one on reads as clearly as the header row; the greySimple *families and Academic/Financial keep those areas text-only, as Word's plain and rule-based styles do. The built-ins mirror the families Word and LibreOffice ship: Simple Grid (+ Rows / Columns), Simple List Shaded / Columns, Plain Table, Box List Blue/Green/Red/Yellow, Grid Table Accent, List Table Accent, Academic, Financial. A style may declare its ownlook— the options it is about, applied over the table's on assignment (styleLook): the list/grid Columns variants switch the bands to columns, Academic drops them (it is a booktabs table) and Financial turns the total row on. Without it the two banding variants would be indistinguishable, since every style defines all six areas.tests/unit/table-style.test.tsasserts no two built-ins render identically — the invariant that catches exactly that. The registry is versioned (STYLE_SHEET_VERSION), so adding or changing one needs a bump. Split of responsibilities: fill and borders are materialized into cell attrs (they already round-trip); text formatting is presentational, rendered bytableStyleCssfrom the cell'sregionattr — the same call the repo already makes for header-row bold, so typed/pasted/IME text inherits it. The rules must also target the cell's blocks (… :is(td,th)[data-region~="X"] :is(p,h1,…,li)), or theStandardparagraph rule outranks them on font/size/color. Theregionattr is stored rather than derived in CSS becausepageBreaks.tsinjects spacer<tr>s that would break anynth-childbanding. ODF/DOCX: the definition lives in the app registry, not the file — ODF has no banding concept (style:family="table"carries width/align/margins only) and LibreOffice Writer bakes its AutoFormats as direct cell formatting too. So only the name and the look travel: the look rides on ODF's own per-areatable:use-first-row-styles/-last-row-/-first-column-/-last-column-/-banding-rows-/-banding-columns-stylesattributes on<table:table>(ODF_LOOK_ATTRS, shared by both sides) and on DOCX's nativew:tblLook(its band flags are inverted). Without it a reopened file would repaint under the default look, sincerefreshTableStylesruns on every load.applyTableStyleNameswalks the table elements with a lookahead, not\b— a hyphen is a word boundary, so\bwould also match<table:table-cell>and misalign the per-table counter. Export mints an empty namedstyle:family="table"in styles.xml (applyNamedStyles) and points the table's automaticTable{n}style at it viastyle:parent-style-name(applyTableStyleNames, mirroringapplyTableMargins); DOCX writesw:tblStyleplus a name-onlyw:style w:type="table"through the docx lib'simportedStyles(it has notableStylesoption, andw:tblStylePris not emitted). The region's text is baked onto the runs on both exports (theforceBoldchannel widened toTextProps). Import reads the name back (namedAncestor(…, 'table')/w:tblStyle→DocxStyles.tableStyleName); the regions are then re-derived byrefreshTableStyles, andApp.sveltekeepssheet.tableacross an import because the file never carried it. Baked bold is only suppressed forHEADER_SHADEcells (the existingboldByDefaultpath) — a foreign style's bold survives as direct formatting. -
Table-style gallery:
TableStylePicker.sveltein the floatingTableToolbar— tiles drawn bypreviewCellCss(the same resolver), so a preview can't disagree with what applying does, plus the six Table Style Options as checkboxes beside them. Each preview cell also carries a schematic text line (previewTextCss: thickness = bold, slant = italic, colour = the region's): without it an emphasis-only area such as first/last column or the total row is invisible in a tile, since the tiles hold no real text — only fills and borders would show. -
A table's own space above/below (
marginTop/marginBottom, cm, beside the two horizontal ones) — ODFfo:margin-top/-bottomon the table style, which LibreOffice honours like a paragraph's. Rendered on the.tableWrapper(TableView, plus the inline style for the static paths). Word has no table-level spacing, so it round-trips through ODF only. the Math Guide's callout tables carry 0.109/0.409cm and lost half a line each without it. The space above rides--space-before, so the document's spacing model adds it to the block above instead of collapsing against it, exactly as a paragraph's does.
Cell metrics measured against LibreOffice
- No space above a cell's first block. LibreOffice adds none there (its
AddParaTableSpacingAtStart); Word does, soeditor.cssdrops it for theaddspacing model only. Measured on the Math Guide: the table row grew 1mm per row without it.pageBreaks.tsexcludes cell leaves from its page-top-drop detection, or that missing padding would read as a decoration to add back. - A painted border is always a whole pixel, whatever width the file declares — Chromium
floors it, LibreOffice reserves the declared width (the Math Guide's cells ask for a 0.05pt hairline).
The excess comes off the cell's
padding-bottom(--border-over,tableCellBorders.ts; the bottom side only, sinceborder-collapsedraws one line per boundary), which is why the cell padding rides four custom properties rather than one shorthand. 0.25mm a row, a page over the Math Guide's length. <th>carries no UA defaults. A repeating ODF header row (table:table-header-rows) becomes atableHeader, and the browser centres and bolds it — neither Word nor LibreOffice does.editor.cssresets both, so the cell follows its own style (the "header row" preset's bold still comes from.cell-header).