import { XmlGeneralNode, XmlNode, XmlParser, XmlTextNode } from '../xml';
import { Zip } from '../zip';
import { Docx } from './docx';
export class DocxParser {
/*
* Word markup intro:
*
* In Word text nodes are contained in "run" nodes (which specifies text
* properties such as font and color). The "run" nodes in turn are
* contained in paragraph nodes which is the core unit of content.
*
* Example:
*
* <-- paragraph
* <-- run
* <-- run properties
* <-- bold
*
* This is text. <-- actual text
*
*
*
* see: http://officeopenxml.com/WPcontentOverview.php
*/
public static readonly PARAGRAPH_NODE = 'w:p';
public static readonly PARAGRAPH_PROPERTIES_NODE = 'w:pPr';
public static readonly RUN_NODE = 'w:r';
public static readonly RUN_PROPERTIES_NODE = 'w:rPr';
public static readonly TEXT_NODE = 'w:t';
public static readonly TABLE_ROW_NODE = 'w:tr';
public static readonly TABLE_CELL_NODE = 'w:tc';
public static readonly NUMBER_PROPERTIES_NODE = 'w:numPr';
//
// constructor
//
constructor(
private readonly xmlParser: XmlParser
) {
}
//
// parse document
//
public load(zip: Zip): Promise {
return Docx.open(zip, this.xmlParser);
}
//
// content manipulation
//
/**
* Split the text node into two text nodes, each with it's own wrapping node.
* Returns the newly created text node.
*
* @param textNode
* @param splitIndex
* @param addBefore Should the new node be added before or after the original node.
*/
public splitTextNode(textNode: XmlTextNode, splitIndex: number, addBefore: boolean): XmlTextNode {
let firstXmlTextNode: XmlTextNode;
let secondXmlTextNode: XmlTextNode;
// split nodes
const wordTextNode = this.containingTextNode(textNode);
const newWordTextNode = XmlNode.cloneNode(wordTextNode, true);
// set space preserve to prevent display differences after splitting
// (otherwise if there was a space in the middle of the text node and it
// is now at the beginning or end of the text node it will be ignored)
this.setSpacePreserveAttribute(wordTextNode);
this.setSpacePreserveAttribute(newWordTextNode);
if (addBefore) {
// insert new node before existing one
XmlNode.insertBefore(newWordTextNode, wordTextNode);
firstXmlTextNode = XmlNode.lastTextChild(newWordTextNode);
secondXmlTextNode = textNode;
} else {
// insert new node after existing one
const curIndex = wordTextNode.parentNode.childNodes.indexOf(wordTextNode);
XmlNode.insertChild(wordTextNode.parentNode, newWordTextNode, curIndex + 1);
firstXmlTextNode = textNode;
secondXmlTextNode = XmlNode.lastTextChild(newWordTextNode);
}
// edit text
const firstText = firstXmlTextNode.textContent;
const secondText = secondXmlTextNode.textContent;
firstXmlTextNode.textContent = firstText.substring(0, splitIndex);
secondXmlTextNode.textContent = secondText.substring(splitIndex);
return (addBefore ? firstXmlTextNode : secondXmlTextNode);
}
/**
* Split the paragraph around the specified text node.
*
* @returns Two paragraphs - `left` and `right`. If the `removeTextNode` argument is
* `false` then the original text node is the first text node of `right`.
*/
public splitParagraphByTextNode(paragraph: XmlNode, textNode: XmlTextNode, removeTextNode: boolean): [XmlNode, XmlNode] {
// input validation
const containingParagraph = this.containingParagraphNode(textNode);
if (containingParagraph != paragraph)
throw new Error(`Node '${nameof(textNode)}' is not a descendant of '${nameof(paragraph)}'.`);
const runNode = this.containingRunNode(textNode);
const wordTextNode = this.containingTextNode(textNode);
// create run clone
const leftRun = XmlNode.cloneNode(runNode, false);
const rightRun = runNode;
XmlNode.insertBefore(leftRun, rightRun);
// copy props from original run node (preserve style)
const runProps = rightRun.childNodes.find(node => node.nodeName === DocxParser.RUN_PROPERTIES_NODE);
if (runProps) {
const leftRunProps = XmlNode.cloneNode(runProps, true);
XmlNode.appendChild(leftRun, leftRunProps);
}
// move nodes from 'right' to 'left'
const firstRunChildIndex = (runProps ? 1 : 0);
let curChild = rightRun.childNodes[firstRunChildIndex];
while (curChild != wordTextNode) {
XmlNode.remove(curChild);
XmlNode.appendChild(leftRun, curChild);
curChild = rightRun.childNodes[firstRunChildIndex];
}
// remove text node
if (removeTextNode) {
XmlNode.removeChild(rightRun, firstRunChildIndex);
}
// create paragraph clone
const leftPara = XmlNode.cloneNode(containingParagraph, false);
const rightPara = containingParagraph;
XmlNode.insertBefore(leftPara, rightPara);
// copy props from original paragraph (preserve style)
const paragraphProps = rightPara.childNodes.find(node => node.nodeName === DocxParser.PARAGRAPH_PROPERTIES_NODE);
if (paragraphProps) {
const leftParagraphProps = XmlNode.cloneNode(paragraphProps, true);
XmlNode.appendChild(leftPara, leftParagraphProps);
}
// move nodes from 'right' to 'left'
const firstParaChildIndex = (paragraphProps ? 1 : 0);
curChild = rightPara.childNodes[firstParaChildIndex];
while (curChild != rightRun) {
XmlNode.remove(curChild);
XmlNode.appendChild(leftPara, curChild);
curChild = rightPara.childNodes[firstParaChildIndex];
}
// clean paragraphs - remove empty runs
if (this.isEmptyRun(leftRun))
XmlNode.remove(leftRun);
if (this.isEmptyRun(rightRun))
XmlNode.remove(rightRun);
return [leftPara, rightPara];
}
/**
* Move all text between the 'from' and 'to' nodes to the 'from' node.
*/
public joinTextNodesRange(from: XmlTextNode, to: XmlTextNode): void {
// find run nodes
const firstRunNode = this.containingRunNode(from);
const secondRunNode = this.containingRunNode(to);
const paragraphNode = firstRunNode.parentNode;
if (secondRunNode.parentNode !== paragraphNode)
throw new Error('Can not join text nodes from separate paragraphs.');
// find "word text nodes"
const firstWordTextNode = this.containingTextNode(from);
const secondWordTextNode = this.containingTextNode(to);
const totalText: string[] = [];
// iterate runs
let curRunNode = firstRunNode;
while (curRunNode) {
// iterate text nodes
let curWordTextNode: XmlNode;
if (curRunNode === firstRunNode) {
curWordTextNode = firstWordTextNode;
} else {
curWordTextNode = this.firstTextNodeChild(curRunNode);
}
while (curWordTextNode) {
if (curWordTextNode.nodeName !== DocxParser.TEXT_NODE)
continue;
// move text to first node
const curXmlTextNode = XmlNode.lastTextChild(curWordTextNode);
totalText.push(curXmlTextNode.textContent);
// next text node
const textToRemove = curWordTextNode;
if (curWordTextNode === secondWordTextNode) {
curWordTextNode = null;
} else {
curWordTextNode = curWordTextNode.nextSibling;
}
// remove current text node
if (textToRemove !== firstWordTextNode) {
XmlNode.remove(textToRemove);
}
}
// next run
const runToRemove = curRunNode;
if (curRunNode === secondRunNode) {
curRunNode = null;
} else {
curRunNode = curRunNode.nextSibling;
}
// remove current run
if (!runToRemove.childNodes || !runToRemove.childNodes.length) {
XmlNode.remove(runToRemove);
}
}
// set the text content
const firstXmlTextNode = XmlNode.lastTextChild(firstWordTextNode);
firstXmlTextNode.textContent = totalText.join('');
}
/**
* Take all runs from 'second' and move them to 'first'.
*/
public joinParagraphs(first: XmlNode, second: XmlNode): void {
if (first === second)
return;
let childIndex = 0;
while (second.childNodes && childIndex < second.childNodes.length) {
const curChild = second.childNodes[childIndex];
if (curChild.nodeName === DocxParser.RUN_NODE) {
XmlNode.removeChild(second, childIndex);
XmlNode.appendChild(first, curChild);
} else {
childIndex++;
}
}
}
public setSpacePreserveAttribute(node: XmlGeneralNode): void {
if (!node.attributes) {
node.attributes = {};
}
if (!node.attributes['xml:space']) {
node.attributes['xml:space'] = 'preserve';
}
}
//
// node queries
//
public isTextNode(node: XmlNode): boolean {
return node.nodeName === DocxParser.TEXT_NODE;
}
public isRunNode(node: XmlNode): boolean {
return node.nodeName === DocxParser.RUN_NODE;
}
public isRunPropertiesNode(node: XmlNode): boolean {
return node.nodeName === DocxParser.RUN_PROPERTIES_NODE;
}
public isTableCellNode(node: XmlNode): boolean {
return node.nodeName === DocxParser.TABLE_CELL_NODE;
}
public isParagraphNode(node: XmlNode): boolean {
return node.nodeName === DocxParser.PARAGRAPH_NODE;
}
public isListParagraph(paragraphNode: XmlNode): boolean {
const paragraphProperties = this.paragraphPropertiesNode(paragraphNode);
const listNumberProperties = XmlNode.findChildByName(paragraphProperties, DocxParser.NUMBER_PROPERTIES_NODE);
return !!listNumberProperties;
}
public paragraphPropertiesNode(paragraphNode: XmlNode): XmlNode {
if (!this.isParagraphNode(paragraphNode))
throw new Error(`Expected paragraph node but received a '${paragraphNode.nodeName}' node.`);
return XmlNode.findChildByName(paragraphNode, DocxParser.PARAGRAPH_PROPERTIES_NODE);
}
/**
* Search for the first direct child **Word** text node (i.e. a node).
*/
public firstTextNodeChild(node: XmlNode): XmlNode {
if (!node)
return null;
if (node.nodeName !== DocxParser.RUN_NODE)
return null;
if (!node.childNodes)
return null;
for (const child of node.childNodes) {
if (child.nodeName === DocxParser.TEXT_NODE)
return child;
}
return null;
}
/**
* Search **upwards** for the first **Word** text node (i.e. a node).
*/
public containingTextNode(node: XmlTextNode): XmlGeneralNode {
if (!node)
return null;
if (!XmlNode.isTextNode(node))
throw new Error(`'Invalid argument ${nameof(node)}. Expected a XmlTextNode.`);
return XmlNode.findParentByName(node, DocxParser.TEXT_NODE) as XmlGeneralNode;
}
/**
* Search **upwards** for the first run node.
*/
public containingRunNode(node: XmlNode): XmlNode {
return XmlNode.findParentByName(node, DocxParser.RUN_NODE);
}
/**
* Search **upwards** for the first paragraph node.
*/
public containingParagraphNode(node: XmlNode): XmlNode {
return XmlNode.findParentByName(node, DocxParser.PARAGRAPH_NODE);
}
/**
* Search **upwards** for the first "table row" node.
*/
public containingTableRowNode(node: XmlNode): XmlNode {
return XmlNode.findParentByName(node, DocxParser.TABLE_ROW_NODE);
}
//
// advanced node queries
//
public isEmptyTextNode(node: XmlNode): boolean {
if (!this.isTextNode(node))
throw new Error(`Text node expected but '${node.nodeName}' received.`);
if (!node.childNodes?.length)
return true;
const xmlTextNode = node.childNodes[0];
if (!XmlNode.isTextNode(xmlTextNode))
throw new Error("Invalid XML structure. 'w:t' node should contain a single text node only.");
if (!xmlTextNode.textContent)
return true;
return false;
}
public isEmptyRun(node: XmlNode): boolean {
if (!this.isRunNode(node))
throw new Error(`Run node expected but '${node.nodeName}' received.`);
for (const child of (node.childNodes ?? [])) {
if (this.isRunPropertiesNode(child))
continue;
if (this.isTextNode(child) && this.isEmptyTextNode(child))
continue;
return false;
}
return true;
}
}