Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
export * from './docx-helpers.js';
export * from './docx-constants.js';
export * from './get-default-style-definition.js';
export * from './xml-node-access.js';
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// @ts-check

/**
* Safe reads over xml-js element trees.
*
* AIDEV-NOTE: xml-js omits `elements` and `attributes` entirely rather than
* emitting empty ones, so `<w:rPr/>`, `<w:rPr />` and `<w:rPr></w:rPr>` all
* parse to `{ type: 'element', name: 'w:rPr' }` with no `elements` key. Every
* child of `CT_Style` is optional in ECMA-376, so empty property containers are
* schema-valid, but readers that assume `node.elements` exists throw on them
* (issue #3861). Reach into parsed OOXML through these helpers rather than
* dereferencing `.elements` or `.attributes` directly.
*/

/**
* A node in a parsed xml-js tree.
* @typedef {object} XmlNode
* @property {string} [type] Node kind, e.g. `element` or `text`.
* @property {string} [name] Qualified element name, e.g. `w:rPr`.
* @property {Record<string, string>} [attributes] Absent when the element has no attributes.
* @property {XmlNode[]} [elements] Absent when the element has no children.
* @property {string} [text] Character data, for text nodes.
*/

/**
* Child elements of a parsed OOXML node, or an empty array when it has none.
* @param {XmlNode | null | undefined} node
* @returns {XmlNode[]}
*/
export const childElements = (node) => (Array.isArray(node?.elements) ? node.elements : []);

/**
* First child element with the given qualified name.
* @param {XmlNode | null | undefined} node
* @param {string} name Qualified element name, e.g. `w:rPr`.
* @returns {XmlNode | undefined}
*/
export const findChild = (node, name) => childElements(node).find((el) => el?.name === name);

/**
* Every child element with the given qualified name.
* @param {XmlNode | null | undefined} node
* @param {string} name Qualified element name, e.g. `w:tblStylePr`.
* @returns {XmlNode[]}
*/
export const findChildren = (node, name) => childElements(node).filter((el) => el?.name === name);

/**
* Attribute value, or undefined when the element carries no attributes.
* @param {XmlNode | null | undefined} node
* @param {string} name Qualified attribute name, e.g. `w:val`.
* @returns {string | undefined}
*/
export const attrValue = (node, name) => node?.attributes?.[name];
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// @ts-check
import { describe, expect, it } from 'vitest';
import * as xmljs from 'xml-js';
import { createDocumentJson } from './docxImporter.js';

/**
* End-to-end import cover for empty OOXML property containers (issue #3861).
*
* AIDEV-NOTE: Fixtures are parsed from XML strings because xml-js omits the
* `elements` key on empty elements. Object literals using `elements: []` do not
* reproduce the failing shape.
*/

const WORDPROCESSING_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
const RELATIONSHIPS_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';

const parse = (xml) => xmljs.xml2js(xml, { compact: false });

const DOCUMENT_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="${WORDPROCESSING_NS}" xmlns:r="${RELATIONSHIPS_NS}">
<w:body>
<w:p><w:r><w:t>before</w:t></w:r></w:p>
<w:tbl>
<w:tblPr><w:tblStyle w:val="TableGrid"/></w:tblPr>
<w:tblGrid><w:gridCol w:w="2880"/></w:tblGrid>
<w:tr><w:tc><w:p><w:r><w:t>cell</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>
<w:p><w:r><w:t>after</w:t></w:r></w:p>
<w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr>
</w:body>
</w:document>`;

/** Styles.xml where Table Grid inherits from a bare base and carries an empty w:rPr. */
const STYLES_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="${WORDPROCESSING_NS}">
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
<w:style w:type="table" w:styleId="TableNormal"/>
<w:style w:type="table" w:styleId="TableGrid">
<w:name w:val="Table Grid"/>
<w:basedOn w:val="TableNormal"/>
<w:rPr/>
</w:style>
</w:styles>`;

const buildDocx = () => ({
'word/document.xml': parse(DOCUMENT_XML),
'word/styles.xml': parse(STYLES_XML),
'word/_rels/document.xml.rels': parse(
`<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>`,
),
});

const buildConverter = () => ({
headerIds: {},
headers: {},
footers: {},
docHiglightColors: new Set(),
trackedChangesOptions: {},
convertedXml: {},
});

const textOf = (node) => {
if (!node) return '';
if (node.type === 'text') return node.text ?? '';
return (node.content ?? []).map(textOf).join('');
};

describe('DOCX import with empty style property containers', () => {
it('keeps a table whose style has an empty w:rPr and a bare basedOn target', () => {
const exceptions = [];
const editor = {
emit: (name, payload) => {
if (name === 'exception') exceptions.push(payload.error);
},
options: {},
};

const result = createDocumentJson(buildDocx(), buildConverter(), editor);

const body = (result?.pmDoc?.content ?? []).map((node) => `${node.type}:${textOf(node)}`);
expect(body).toEqual(['paragraph:before', 'table:cell', 'paragraph:after']);
expect(exceptions).toEqual([]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// @ts-check
import { describe, expect, it } from 'vitest';
import * as xmljs from 'xml-js';
import { _getReferencedTableStyles } from './tbl-translator.js';

/**
* Empty property containers in `word/styles.xml` used to abort table import (issue #3861).
*
* AIDEV-NOTE: These fixtures parse real XML strings on purpose. xml-js omits the
* `elements` key for empty elements, and hand-authored nodes using `elements: []`
* do NOT reproduce that shape, so object-literal fixtures pass against the bug.
* Keep building these from XML.
*/

const WORDPROCESSING_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';

/**
* Build a `docx` bundle whose Table Grid style carries the given child XML.
* @param {string} tableGridChildren Raw XML placed inside `<w:style w:styleId="TableGrid">`.
* @param {string} [extraStyles] Raw XML for sibling `<w:style>` elements.
*/
const docxWithTableGrid = (tableGridChildren, extraStyles = '') => ({
'word/styles.xml': xmljs.xml2js(
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="${WORDPROCESSING_NS}">
<w:style w:type="table" w:styleId="TableNormal"><w:name w:val="Normal Table"/></w:style>
${extraStyles}
<w:style w:type="table" w:styleId="TableGrid">
<w:name w:val="Table Grid"/>
${tableGridChildren}
</w:style>
</w:styles>`,
{ compact: false },
),
});

const resolve = (docx, styleId = 'TableGrid') => _getReferencedTableStyles(styleId, { docx });

describe('_getReferencedTableStyles with empty style properties', () => {
describe('empty run properties (the reported case)', () => {
it.each([
['self-closing', '<w:rPr/>'],
['self-closing with space', '<w:rPr />'],
['paired', '<w:rPr></w:rPr>'],
['whitespace only', '<w:rPr>\n </w:rPr>'],
])('treats a %s w:rPr as a no-op', (_label, rPr) => {
const styles = resolve(docxWithTableGrid(rPr));

expect(styles).not.toBeNull();
expect(styles?.fonts).toBeUndefined();
expect(styles?.fontSize).toBeUndefined();
});

it('still reads populated run properties', () => {
const styles = resolve(
docxWithTableGrid('<w:rPr><w:rFonts w:ascii="Arial" w:hAnsi="Arial" w:cs="Arial"/><w:sz w:val="24"/></w:rPr>'),
);

expect(styles?.fonts).toEqual({ ascii: 'Arial', hAnsi: 'Arial', cs: 'Arial' });
expect(styles?.fontSize).toBe('12pt');
});

it('treats a w:rFonts without attributes as a no-op', () => {
const styles = resolve(docxWithTableGrid('<w:rPr><w:rFonts/></w:rPr>'));

expect(styles?.fonts).toBeUndefined();
});
});

describe('empty paragraph properties', () => {
it.each([
['self-closing', '<w:pPr/>'],
['paired', '<w:pPr></w:pPr>'],
])('treats a %s w:pPr as a no-op', (_label, pPr) => {
const styles = resolve(docxWithTableGrid(pPr));

expect(styles?.justification).toBeUndefined();
});

it('still reads justification from a populated w:pPr', () => {
const styles = resolve(docxWithTableGrid('<w:pPr><w:jc w:val="center"/></w:pPr>'));

expect(styles?.justification).toBe('center');
});
});

describe('inherited styles', () => {
it('tolerates a w:basedOn target that has no children', () => {
const docx = docxWithTableGrid(
'<w:basedOn w:val="BareBase"/><w:rPr/>',
'<w:style w:type="table" w:styleId="BareBase"/>',
);

expect(() => resolve(docx)).not.toThrow();
});

it('tolerates a w:basedOn target that does not exist', () => {
expect(() => resolve(docxWithTableGrid('<w:basedOn w:val="Missing"/>'))).not.toThrow();
});

it('still inherits table properties from a populated base style', () => {
const docx = docxWithTableGrid(
'<w:basedOn w:val="BorderedBase"/><w:tblPr><w:tblStyleRowBandSize w:val="1"/></w:tblPr>',
`<w:style w:type="table" w:styleId="BorderedBase">
<w:tblPr><w:tblBorders><w:top w:val="single" w:sz="4"/></w:tblBorders></w:tblPr>
</w:style>`,
);

expect(resolve(docx)?.borders?.top).toMatchObject({ val: 'single' });
});
});

describe('conditional formatting', () => {
it('skips a w:tblStylePr that has no w:type to key it by', () => {
const styles = resolve(docxWithTableGrid('<w:tblStylePr><w:tcPr/></w:tblStylePr>'));

expect(styles).not.toBeNull();
expect(Object.keys(styles ?? {})).not.toContain('undefined');
});

it('still reads a typed w:tblStylePr', () => {
const styles = resolve(docxWithTableGrid('<w:tblStylePr w:type="firstRow"><w:rPr><w:b/></w:rPr></w:tblStylePr>'));

expect(styles?.firstRow).toBeDefined();
});

it('treats empty conditional property containers as no-ops', () => {
const styles = resolve(
docxWithTableGrid('<w:tblStylePr w:type="firstRow"><w:tcPr/><w:rPr/><w:pPr/><w:trPr/></w:tblStylePr>'),
);

expect(styles?.firstRow).toBeDefined();
});
});

describe('malformed style records', () => {
it('tolerates a matched style element with no children', () => {
const docx = {
'word/styles.xml': xmljs.xml2js(
`<?xml version="1.0"?><w:styles xmlns:w="${WORDPROCESSING_NS}">
<w:style w:type="table" w:styleId="TableGrid"/>
</w:styles>`,
{ compact: false },
),
};

expect(() => resolve(docx)).not.toThrow();
});

it('ignores style elements that carry no attributes', () => {
const docx = {
'word/styles.xml': xmljs.xml2js(
`<?xml version="1.0"?><w:styles xmlns:w="${WORDPROCESSING_NS}">
<w:style><w:name w:val="Orphan"/></w:style>
<w:style w:type="table" w:styleId="TableGrid"><w:name w:val="Table Grid"/></w:style>
</w:styles>`,
{ compact: false },
),
};

expect(resolve(docx)?.name).toBeDefined();
});

it('returns null when styles.xml is absent', () => {
expect(resolve({})).toBeNull();
});

it('treats empty w:tblPr, w:tcPr and w:trPr as no-ops', () => {
const styles = resolve(docxWithTableGrid('<w:tblPr/><w:tcPr/><w:trPr/>'));

expect(styles?.borders).toBeUndefined();
expect(styles?.cellMargins).toBeUndefined();
});
});
});
Loading
Loading