fix: give docx export tables real column widths and cell padding (#2564)

- Add Word's default 108 twip left and right cell margins
This commit is contained in:
Philip Okugbe 2026-10-11 15:07:26 +01:00 committed by GitHub
parent 59b6142ecd
commit 6a06d7fd7d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 93 additions and 8 deletions

@ -1 +1 @@
Subproject commit 71625f8e246a7680991302fede87ba999c9db9da
Subproject commit 662a0042eb6c56a01f8958447780fe2354467b14

View file

@ -26,10 +26,18 @@ import {
ITableOptions,
ITableRowOptions,
IPropertiesOptions,
WidthType,
} from 'docx';
import { imageDimensionsFromData } from 'image-dimensions';
import { createNumbering, NumberingStyles } from './numbering';
import { buildDoc, createShortId } from './utils';
import {
buildDoc,
CELL_MARGIN,
createShortId,
getTableCellWidths,
getTableColumnWidths,
TEXT_WIDTH,
} from './utils';
import { IFootnotes, INumbering, Mutable, SectionConfig, SerializationState } from './types';
// This is duplicated from @curvenote/schema
@ -337,7 +345,9 @@ export class DocxSerializerState {
const { getCellOptions, getRowOptions, tableOptions } = opts;
const actualChildren = this.children;
const rows: TableRow[] = [];
node.content.forEach((row) => {
const columnWidths = getTableColumnWidths(node);
const cellWidths = getTableCellWidths(node, columnWidths);
node.content.forEach((row, _rowOffset, rowIndex) => {
const cells: TableCell[] = [];
// Check if all cells are headers in this row
let tableHeader = true;
@ -348,10 +358,13 @@ export class DocxSerializerState {
});
// This scales images inside of tables
this.maxImageWidth = MAX_IMAGE_WIDTH / row.content.childCount;
row.content.forEach((cell) => {
row.content.forEach((cell, _cellOffset, cellIndex) => {
this.children = [];
this.renderContent(cell);
const tableCellOpts: Mutable<ITableCellOptions> = { children: this.children };
const tableCellOpts: Mutable<ITableCellOptions> = {
children: this.children,
width: { size: cellWidths[rowIndex][cellIndex], type: WidthType.DXA },
};
const colspan = cell.attrs.colspan ?? 1;
const rowspan = cell.attrs.rowspan ?? 1;
if (colspan > 1) tableCellOpts.columnSpan = colspan;
@ -366,7 +379,13 @@ export class DocxSerializerState {
rows.push(new TableRow({ ...(getRowOptions?.(row) || {}), children: cells, tableHeader }));
});
this.maxImageWidth = MAX_IMAGE_WIDTH;
const table = new Table({ ...tableOptions, rows });
const table = new Table({
width: { size: TEXT_WIDTH, type: WidthType.DXA },
columnWidths,
margins: { left: CELL_MARGIN, right: CELL_MARGIN },
...tableOptions,
rows,
});
actualChildren.push(table);
// If there are multiple tables, this seperates them
actualChildren.push(new Paragraph(''));
@ -763,6 +782,8 @@ export class DocxSerializerStateAsync {
const { getCellOptions, getRowOptions, tableOptions } = opts;
const actualChildren = this.children;
const rows: TableRow[] = [];
const columnWidths = getTableColumnWidths(node);
const cellWidths = getTableCellWidths(node, columnWidths);
for (let rowIndex = 0; rowIndex < node.content.childCount; rowIndex += 1) {
const row = node.content.child(rowIndex);
@ -786,7 +807,10 @@ export class DocxSerializerStateAsync {
this.children = [];
// eslint-disable-next-line no-await-in-loop
await this.renderContent(cell); // Ensure order
const tableCellOpts: Mutable<ITableCellOptions> = { children: this.children };
const tableCellOpts: Mutable<ITableCellOptions> = {
children: this.children,
width: { size: cellWidths[rowIndex][cellIndex], type: WidthType.DXA },
};
const colspan = cell.attrs.colspan ?? 1;
const rowspan = cell.attrs.rowspan ?? 1;
if (colspan > 1) tableCellOpts.columnSpan = colspan;
@ -803,7 +827,13 @@ export class DocxSerializerStateAsync {
}
this.maxImageWidth = MAX_IMAGE_WIDTH;
const table = new Table({ ...tableOptions, rows });
const table = new Table({
width: { size: TEXT_WIDTH, type: WidthType.DXA },
columnWidths,
margins: { left: CELL_MARGIN, right: CELL_MARGIN },
...tableOptions,
rows,
});
actualChildren.push(table);
// If there are multiple tables, this separates them
actualChildren.push(new Paragraph(''));

View file

@ -5,14 +5,69 @@ import {
ISectionOptions,
Packer,
SectionType,
sectionMarginDefaults,
sectionPageSizeDefaults,
} from 'docx';
import { Node as ProsemirrorNode } from 'prosemirror-model';
import { TableMap } from '@tiptap/pm/tables';
import { IFootnotes, SerializationState } from './types';
// twips between the margins of docx's default page
export const TEXT_WIDTH =
sectionPageSizeDefaults.WIDTH - sectionMarginDefaults.LEFT - sectionMarginDefaults.RIGHT;
// twips of left and right cell padding Word gives tables by default
export const CELL_MARGIN = 108;
// colwidth is in editor px; these are the editor's default content width and cellMinWidth
const EDITOR_WIDTH = 772;
const CELL_MIN_WIDTH = 49;
export function createShortId() {
return Math.random().toString(36).slice(2, 11);
}
function sum(values: number[]) {
return values.reduce((total, value) => total + value, 0);
}
// Spreads TEXT_WIDTH over the columns in their editor proportions. Columns never resized
// have no colwidth and share what the resized ones leave of the editor width.
export function getTableColumnWidths(table: ProsemirrorNode): number[] {
const colwidths: number[] = [];
table.firstChild?.forEach((cell) => {
for (let i = 0; i < cell.attrs.colspan; i += 1) {
colwidths.push(cell.attrs.colwidth?.[i] ?? 0);
}
});
const columnCount = TableMap.get(table).width;
const resized = colwidths.filter((width) => width > 0);
const unresizedCount = columnCount - resized.length;
const share = unresizedCount ? (EDITOR_WIDTH - sum(resized)) / unresizedCount : 0;
const widths = Array.from({ length: columnCount }, (_, i) =>
Math.max(colwidths[i] || share, CELL_MIN_WIDTH),
);
const total = sum(widths);
return widths.map((width) => Math.round((width / total) * TEXT_WIDTH));
}
// Each cell, by row and cell index, is as wide as the columns it spans.
export function getTableCellWidths(table: ProsemirrorNode, columnWidths: number[]): number[][] {
const map = TableMap.get(table);
const cellWidths: number[][] = [];
table.forEach((row, rowOffset) => {
const rowWidths: number[] = [];
row.forEach((_cell, cellOffset) => {
const { left, right } = map.findCell(rowOffset + 1 + cellOffset);
rowWidths.push(sum(columnWidths.slice(left, right)));
});
cellWidths.push(rowWidths);
});
return cellWidths;
}
export function buildDoc(state: SerializationState, opts?: IPropertiesOptions): Document {
let sections = state?.sections?.length
? state.sections.map((section) => ({