diff --git a/examples/table-spanned-row-margins.tsx b/examples/table-spanned-row-margins.tsx new file mode 100644 index 0000000..606c328 --- /dev/null +++ b/examples/table-spanned-row-margins.tsx @@ -0,0 +1,82 @@ +// deno-lint-ignore-file jsx-key +/** @jsx Docx.jsx */ + +import Docx, { + type CellMargin, + Cell, + Paragraph, + pt, + Row, + Section, + Table, + twip, +} from '../mod.ts'; + +const FIRST_ROW_MARGIN: CellMargin = { top: pt(20), bottom: pt(20) }; +const SPANNED_ROW_MARGIN: CellMargin = { top: pt(0), bottom: pt(0) }; +const BORDER = { type: 'single', width: pt(0.5), color: '000000' } as const; + +/** + * A header-like table with the first column merged over three rows. The first row has larger top + * and bottom margins, while the spanned rows can have smaller margins through `spannedRowMargins`. + */ +function createTable(spannedRowMargins?: Array) { + return ( + + + + Logo + + + Company + + + + + + Document Title: <Document Title> + + + + + + Doc.No.: <Doc.No.> + + +
+ ); +} + +await Docx.fromJsx([ +
+ + Without spannedRowMargins: rows 2 and 3 repeat the 20pt top and + bottom margins of the merged grey cell, so they are much taller than + their text. + + {createTable()} + + + With spannedRowMargins: rows 2 and 3 use their own 0pt margins, so + they are only as tall as their text. + + {createTable([SPANNED_ROW_MARGIN, SPANNED_ROW_MARGIN])} +
, +]).toFile('table-spanned-row-margins.docx'); diff --git a/lib/components/document/src/Cell.ts b/lib/components/document/src/Cell.ts index 6b4b9e4..78918e4 100644 --- a/lib/components/document/src/Cell.ts +++ b/lib/components/document/src/Cell.ts @@ -38,10 +38,35 @@ export type CellChild = | Insertion | Deletion; +/** + * The margins of one table cell, as in {@link TableCellProperties.margin}. + */ +export type CellMargin = NonNullable; + /** * A type describing the props accepted by {@link Cell}. */ -export type CellProps = Omit; +export type CellProps = Omit & { + /** + * Margins for the rows spanned by a vertically merged cell, + * one entry per spanned row after the first. + * + * `null` means that the row has no own margins. If omitted, all spanned rows + * use {@link TableCellProperties.margin}. + * + * @example + * // A cell spanning three rows with smaller margins in rows 2 and 3: + * { + * rowSpan: 3, + * margin: { top: twip(43), bottom: twip(43) }, + * spannedRowMargins: [ + * { top: twip(14), bottom: twip(14) }, + * { top: twip(14), bottom: twip(14) } + * ] + * } + */ + spannedRowMargins?: null | Array; +}; /** * A component that represents a table cell. @@ -125,11 +150,10 @@ export class Cell extends Component { ); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars public toRepeatingNode( ancestry: ComponentAncestor[], column: number, - _row: number + row: number ): Node | null { const table = ancestry.find( (ancestor): ancestor is Table => ancestor instanceof Table @@ -146,6 +170,11 @@ export class Cell extends Component { return null; } + // Each spanned row keeps its own margins, which MS Word uses for the row height. + const { spannedRowMargins, ...cellProps } = this.props; + // This is the margin of the merged cell in this row + const spannedRowMargin = spannedRowMargins?.[row - info.row - 1]; + return create( `element ${QNS.w}tc { $tcPr, @@ -157,7 +186,13 @@ export class Cell extends Component { width: this.getCellWidth(table), colSpan: this.getColSpan(), rowSpan: this.getRowSpan(), - ...this.props, + ...cellProps, + // If `spannedRowMargins` has no entry for a row, + // that row falls back to the first row's margins, preserving the previous behavior. + margin: + spannedRowMargin === undefined + ? cellProps.margin + : spannedRowMargin, }, true ), @@ -225,10 +260,15 @@ export class Cell extends Component { * We should consider aligning both. */ - const { mergedAway, children, ...props } = evaluateXPathToMap< - CellProps & { mergedAway: boolean; children: Node[] } - >( - ` + const { mergedAway, children, spannedRowCells, ...props } = + evaluateXPathToMap< + CellProps & { + mergedAway: boolean; + children: Node[]; + spannedRowCells: Array; + } + >( + ` let $colStart := docxml:cell-column(.) let $rowStart := count(../preceding-sibling::${QNS.w}tr) @@ -254,6 +294,48 @@ export class Cell extends Component { then ./${QNS.w}tcPr/${QNS.w}gridSpan/@${QNS.w}val/number() else 1, "rowSpan": $rowEnd - $rowStart, + (: The margins of the continuation cells still affect row height in Word, + so they are preserved in spannedRowMargins. :) + "spannedRowCells": array { + + (: Rows below this one that are still covered by the merged cell. + For example, the next 2 rows when rowSpan is 3. :) + for $row in ../following-sibling::${QNS.w}tr[ + position() lt ($rowEnd - $rowStart) + ] + + (: Find the cell in the same column as the merged cell, + then get its margins. :) + let $tcMar := + $row/${QNS.w}tc[ + docxml:spans-cell-column(., $colStart) + ]/${QNS.w}tcPr/${QNS.w}tcMar + + return + if (exists($tcMar)) then + $tcMar/map { + "top": docxml:length( + ${QNS.w}top/@${QNS.w}w, + 'twip' + ), + (: Support both w:start and w:left for compatibility. :) + "start": docxml:length( + ($tcMar/${QNS.w}start | $tcMar/${QNS.w}left)[1]/@${QNS.w}w, + 'twip' + ), + "bottom": docxml:length( + ${QNS.w}bottom/@${QNS.w}w, + 'twip' + ), + (: Support both w:end and w:right for compatibility. :) + "end": docxml:length( + ($tcMar/${QNS.w}end | $tcMar/${QNS.w}right)[1]/@${QNS.w}w, + 'twip' + ) + } + else + map { "isNull": true() } + }, "children": array{ ./(${QNS.w}p) }, "shading": ./${QNS.w}tcPr/${QNS.w}shd/docxml:ct-shd(.), "borders": ./${QNS.w}tcPr/${QNS.w}tcBorders/map { @@ -285,12 +367,21 @@ export class Cell extends Component { } } `, - node - ); + node + ); if (mergedAway) { return null; } + const hasSpannedRowMargins = spannedRowCells.some( + (margin) => !('isNull' in margin) + ); + if (hasSpannedRowMargins) { + props.spannedRowMargins = spannedRowCells.map((margin) => + 'isNull' in margin ? null : margin + ); + } + // Convert the date string to a Date object. if (props.insertion) { props.insertion.date = props.insertion.date diff --git a/lib/components/document/test/Cell.test.ts b/lib/components/document/test/Cell.test.ts index f4bb88d..99d8fb6 100644 --- a/lib/components/document/test/Cell.test.ts +++ b/lib/components/document/test/Cell.test.ts @@ -5,12 +5,15 @@ import { Archive } from '../../../classes/src/Archive.ts'; import { Bookmarks } from '../../../classes/src/Bookmarks.ts'; import type { ComponentContext } from '../../../classes/src/Component.ts'; import { create } from '../../../utilities/src/dom.ts'; +import { twip } from '../../../utilities/src/length.ts'; import { NamespaceUri } from '../../../utilities/src/namespaces.ts'; import { + evaluateXPathToArray, evaluateXPathToFirstNode, evaluateXPathToNodes, } from '../../../utilities/src/xquery.ts'; import { Cell } from '../src/Cell.ts'; +import { Row } from '../src/Row.ts'; import { Table } from '../src/Table.ts'; const emptyContext: ComponentContext = { @@ -288,3 +291,124 @@ describe('Cell - with borders', () => { ); }); }); + +describe('Cell margins of vertically merged rows', () => { + // Mirrors a Word template where the merged cell has different margins + // in the rows it spans. + const dom = create(` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `); + + it('reads the margins of each spanned row', () => { + const cell = Cell.fromNode( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + evaluateXPathToFirstNode('.//*[@xid="merged"]', dom)!, + emptyContext + ); + + expect(cell?.props.rowSpan).toBe(3); + expect(cell?.props.margin?.top?.twip).toBe(43); + expect(cell?.props.spannedRowMargins).toHaveLength(2); + expect(cell?.props.spannedRowMargins?.[0]?.top?.twip).toBe(14); + expect(cell?.props.spannedRowMargins?.[0]?.bottom?.twip).toBe(14); + expect(cell?.props.spannedRowMargins?.[1]).toBeNull(); + }); + + it('writes the margins of each spanned row', async () => { + const table = Table.fromNode(dom, emptyContext); + const node = await table.toNode([]); + + expect( + evaluateXPathToArray( + `array { ./*[local-name() = "tr"]/*[local-name() = "tc"][1]/string( + ./*[local-name() = "tcPr"]/*[local-name() = "tcMar"]/*[local-name() = "top"]/@*[local-name() = "w"] + ) }`, + node + ) + ).toEqual(['43', '14', '']); + }); + + it('uses the first row margins when spannedRowMargins is not set', async () => { + const table = new Table( + { columnWidths: [twip(2879), twip(7160)] }, + new Row( + {}, + new Cell({ + rowSpan: 2, + margin: { top: twip(43), bottom: twip(43) }, + }), + new Cell({}) + ), + new Row({}, new Cell({})) + ); + + const node = await table.toNode([]); + + expect( + evaluateXPathToArray( + `array { ./*[local-name() = "tr"]/*[local-name() = "tc"][1]/string( + ./*[local-name() = "tcPr"]/*[local-name() = "tcMar"]/*[local-name() = "top"]/@*[local-name() = "w"] + ) }`, + node + ) + ).toEqual(['43', '43']); + }); + + it('does not set spannedRowMargins when no spanned row has its own margins', () => { + const noMarginsDom = create(` + + + + + + + + + + + + + `); + + const cell = Cell.fromNode( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + evaluateXPathToFirstNode('.//*[@xid="merged"]', noMarginsDom)!, + emptyContext + ); + + expect(cell?.props.rowSpan).toBe(2); + expect(cell && 'spannedRowMargins' in cell.props).toBe(false); + }); +}); diff --git a/mod.ts b/mod.ts index 6bbee5b..ceb2e5a 100644 --- a/mod.ts +++ b/mod.ts @@ -56,6 +56,7 @@ export { export { Cell, type CellChild, + type CellMargin, type CellProps, } from './lib/components/document/src/Cell.ts'; export {