Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions examples/table-spanned-row-margins.tsx
Original file line number Diff line number Diff line change
@@ -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<CellMargin | null>) {
return (
<Table
columnWidths={[twip(2879), twip(7160)]}
cellPadding={{ start: twip(14), end: twip(14) }}
borders={{
top: BORDER,
start: BORDER,
bottom: BORDER,
end: BORDER,
insideH: BORDER,
insideV: BORDER,
}}
>
<Row>
<Cell
rowSpan={3}
verticalAlignment='center'
shading={{ background: 'D9D9D9' }}
margin={FIRST_ROW_MARGIN}
spannedRowMargins={spannedRowMargins}
>
<Paragraph>Logo</Paragraph>
</Cell>
<Cell verticalAlignment='center' margin={FIRST_ROW_MARGIN}>
<Paragraph alignment='right'>Company</Paragraph>
</Cell>
</Row>
<Row>
<Cell verticalAlignment='center'>
<Paragraph>
Document Title: &lt;Document Title&gt;
</Paragraph>
</Cell>
</Row>
<Row>
<Cell verticalAlignment='center'>
<Paragraph>Doc.No.: &lt;Doc.No.&gt;</Paragraph>
</Cell>
</Row>
</Table>
);
}

await Docx.fromJsx([
<Section>
<Paragraph>
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.
</Paragraph>
{createTable()}
<Paragraph />
<Paragraph>
With spannedRowMargins: rows 2 and 3 use their own 0pt margins, so
they are only as tall as their text.
</Paragraph>
{createTable([SPANNED_ROW_MARGIN, SPANNED_ROW_MARGIN])}
</Section>,
]).toFile('table-spanned-row-margins.docx');
111 changes: 101 additions & 10 deletions lib/components/document/src/Cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,35 @@ export type CellChild =
| Insertion
| Deletion;

/**
* The margins of one table cell, as in {@link TableCellProperties.margin}.
*/
export type CellMargin = NonNullable<TableCellProperties['margin']>;

/**
* A type describing the props accepted by {@link Cell}.
*/
export type CellProps = Omit<TableCellProperties, 'width'>;
export type CellProps = Omit<TableCellProperties, 'width'> & {
/**
* 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<CellMargin | null>;
};

/**
* A component that represents a table cell.
Expand Down Expand Up @@ -125,11 +150,10 @@ export class Cell extends Component<CellProps, CellChild> {
);
}

// 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
Expand All @@ -146,6 +170,11 @@ export class Cell extends Component<CellProps, CellChild> {
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,
Expand All @@ -157,7 +186,13 @@ export class Cell extends Component<CellProps, CellChild> {
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
),
Expand Down Expand Up @@ -225,10 +260,15 @@ export class Cell extends Component<CellProps, CellChild> {
* 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<CellMargin | { isNull: true }>;
}
>(
`
let $colStart := docxml:cell-column(.)

let $rowStart := count(../preceding-sibling::${QNS.w}tr)
Expand All @@ -254,6 +294,48 @@ export class Cell extends Component<CellProps, CellChild> {
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 {
Expand Down Expand Up @@ -285,12 +367,21 @@ export class Cell extends Component<CellProps, CellChild> {
}
}
`,
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
Expand Down
124 changes: 124 additions & 0 deletions lib/components/document/test/Cell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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(`<w:tbl xmlns:w="${NamespaceUri.w}">
<w:tblGrid>
<w:gridCol w:w="2879" />
<w:gridCol w:w="7160" />
</w:tblGrid>
<w:tr>
<w:tc xid="merged">
<w:tcPr>
<w:vMerge w:val="restart"/>
<w:tcMar><w:top w:w="43" w:type="dxa"/><w:bottom w:w="43" w:type="dxa"/></w:tcMar>
</w:tcPr>
<w:p/>
</w:tc>
<w:tc><w:p/></w:tc>
</w:tr>
<w:tr>
<w:tc>
<w:tcPr>
<w:vMerge/>
<w:tcMar><w:top w:w="14" w:type="dxa"/><w:bottom w:w="14" w:type="dxa"/></w:tcMar>
</w:tcPr>
<w:p/>
</w:tc>
<w:tc><w:p/></w:tc>
</w:tr>
<w:tr>
<w:tc>
<w:tcPr>
<w:vMerge/>
</w:tcPr>
<w:p/>
</w:tc>
<w:tc><w:p/></w:tc>
</w:tr>
</w:tbl>`);

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(`<w:tbl xmlns:w="${NamespaceUri.w}">
<w:tblGrid>
<w:gridCol w:w="2879" />
</w:tblGrid>
<w:tr>
<w:tc xid="merged">
<w:tcPr><w:vMerge w:val="restart"/></w:tcPr>
<w:p/>
</w:tc>
</w:tr>
<w:tr>
<w:tc><w:tcPr><w:vMerge/></w:tcPr><w:p/></w:tc>
</w:tr>
</w:tbl>`);

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);
});
});
Loading