diff --git a/apps/server/src/ee b/apps/server/src/ee index 71625f8e..662a0042 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 71625f8e246a7680991302fede87ba999c9db9da +Subproject commit 662a0042eb6c56a01f8958447780fe2354467b14 diff --git a/packages/editor-ext/src/lib/prosemirror-docx/serializer.ts b/packages/editor-ext/src/lib/prosemirror-docx/serializer.ts index 739f6f51..8745bd69 100644 --- a/packages/editor-ext/src/lib/prosemirror-docx/serializer.ts +++ b/packages/editor-ext/src/lib/prosemirror-docx/serializer.ts @@ -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 = { children: this.children }; + const tableCellOpts: Mutable = { + 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 = { children: this.children }; + const tableCellOpts: Mutable = { + 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('')); diff --git a/packages/editor-ext/src/lib/prosemirror-docx/utils.ts b/packages/editor-ext/src/lib/prosemirror-docx/utils.ts index 60378b10..c8ef9e3e 100644 --- a/packages/editor-ext/src/lib/prosemirror-docx/utils.ts +++ b/packages/editor-ext/src/lib/prosemirror-docx/utils.ts @@ -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) => ({