该类用于处理 Word 文档(XML 结构)中被批注标记的文本范围,
实现指定内容的深度克隆,并将其插入到目标节点之后。
适用于在生成或修改 .docx 文件时复制批注内容块。
/**
* Word XML 批注范围克隆处理器
*
* 该类用于处理 Word 文档(XML 结构)中被批注标记的文本范围,
* 实现指定内容的深度克隆,并将其插入到目标节点之后。
* 适用于在生成或修改 .docx 文件时复制批注内容块。
*/
class WordCommentRangeCloner
{
/**
* 存储文档节点的线性列表,用于通过索引快速访问
* @var array
*/
private $domList = [];
/**
* 记录每个节点索引关联的命名信息,用于模板或块引用
* 格式: [index] => [['type', 'name'], ...]
* @var array
*/
private $domIdxToName = [];
/**
* 存储按名称组织的块引用,支持多实例
* 格式: ['blockName#1'] => ['type' => [index1, index2, ...]]
* @var array
*/
private $blocks = [];
/**
* 扩展索引映射,用于记录节点间的附加关联关系
* 格式: [index] => [relatedIndex1, relatedIndex2, ...]
* @var array
*/
private $idxExtendIdxs = [];
/**
* 将克隆的节点插入到目标节点之后
*
* 如果目标节点有下一个兄弟节点,则插入到其前;
* 否则作为父节点的最后一个子节点追加。
*
* @param \DOMNode $copy 要插入的已克隆节点
* @param \DOMNode $targetNode 目标位置的参考节点
* @return void
*/
public function insertAfter($copy, $targetNode)
{
if ($nextSibling = $targetNode->nextSibling) {
// 如果存在下一个兄弟节点,则插入到它前面
if ($parentNode = $nextSibling->parentNode) {
$parentNode->insertBefore($copy, $nextSibling);
}
} else {
// 否则作为父节点的最后一个子节点添加
if ($parentNode = $targetNode->parentNode) {
$parentNode->appendChild($copy);
}
}
}
/**
* 克隆指定索引的节点及其子树,并插入到指定位置后
*
* 此方法用于复制 Word 文档中被批注包裹的内容块。
* 支持维护索引、命名、关联等元数据一致性。
*
* @param int $nodeIdx 要克隆的原始节点在 domList 中的索引
* @param int $endNodeIdx 插入位置的参考节点索引(插入到该节点之后)
* @param string $name 原始块名称(用于命名新实例)
* @param int $idx 克隆实例编号(用于区分多个副本,如 #1, #2)
* @param int $reIdx 递归层级编号(嵌套克隆时使用)
* @return int 返回克隆子树在 domList 中的起始索引
*/
private function cloneNode($nodeIdx, $endNodeIdx, $name, $idx, $reIdx = 0)
{
$originalNode = $this->domList[$nodeIdx];
$clonedNode = clone $originalNode;
// 为克隆节点重建树索引(使用引用传递维护连续索引)
$startIndex = count($this->domList);
$this->treeToList($clonedNode, $startIndex);
// 构建原始索引到新索引的映射表
$oldToNewMap = $this->buildIndexMap($originalNode, $clonedNode);
// 更新命名映射关系(domIdxToName 和 blocks)
$this->updateNameMappings($originalNode, $oldToNewMap, $idx);
// 更新扩展索引关联关系
$this->updateExtendIdxs($originalNode, $oldToNewMap);
// 将克隆的节点插入到目标节点之后
$this->insertAfter($clonedNode, $this->domList[$endNodeIdx]);
return $startIndex;
}
/**
* 遍历节点树并生成线性节点列表,同时设置每个节点的起止索引
*
* 用于重建克隆节点或新插入节点的索引结构。
*
* @param \DOMNode $node 当前处理的节点
* @param int|null $index 引用传递的当前索引值
* @return void
*/
protected function treeToList($node, &$index = null)
{
if ($index === null) {
$index = count($this->domList);
}
if (is_null($node)) {
return;
}
$node->idxBegin = $index;
$node->tagList = [];
$this->domList[$index++] = $node;
if ($node->hasChildNodes()) {
foreach ($node->childNodes as $childNode) {
if ($childNode->nodeType !== XML_TEXT_NODE) { // 非文本节点
$tagName = $childNode->tagName;
$node->tagList[$tagName][] = $childNode;
$this->treeToList($childNode, $index);
// 合并后代标签列表
foreach ($childNode->tagList as $tag => $nodes) {
foreach ($nodes as $n) {
$node->tagList[$tag][] = $n;
}
}
}
}
}
$node->idxEnd = $index - 1;
}
/**
* 构建原始节点索引到克隆节点索引的映射表
*
* @param \DOMNode $orig 原始节点
* @param \DOMNode $clone 克隆节点
* @return array 映射表 [oldIndex => newIndex]
*/
private function buildIndexMap($orig, $clone)
{
$map = [];
$this->walkNodeAndMap($orig, $clone, $map);
return $map;
}
/**
* 递归遍历原始与克隆节点,建立索引映射
*
* @param \DOMNode $orig
* @param \DOMNode $clone
* @param array &$map 引用传递的映射表
* @return void
*/
private function walkNodeAndMap($orig, $clone, &$map)
{
$map[$orig->idxBegin] = $clone->idxBegin;
if ($orig->hasChildNodes()) {
$origChildren = iterator_to_array($orig->childNodes);
$cloneChildren = iterator_to_array($clone->childNodes);
$j = 0;
foreach ($origChildren as $child) {
if ($child->nodeType !== XML_TEXT_NODE) {
$map = $j < count($cloneChildren) ? $cloneChildren[$j] : null;
if ($map) {
$this->walkNodeAndMap($child, $map, $map);
$j++;
}
}
}
}
}
/**
* 更新命名映射表(domIdxToName 和 blocks)
*
* 为克隆节点分配新名称(如 name#1),并注册到 blocks 中
*
* @param \DOMNode $originalNode 原始节点
* @param array $oldToNewMap 索引映射表
* @param int $cloneIdx 克隆编号
* @return void
*/
private function updateNameMappings($originalNode, $oldToNewMap, $cloneIdx)
{
$begin = $originalNode->idxBegin;
$end = $originalNode->idxEnd;
for ($i = $begin; $i <= $end; $i++) {
if (isset($this->domIdxToName[$i])) {
$newIdx = $oldToNewMap[$i];
$nameTemps = $this->domIdxToName[$i];
foreach ($nameTemps as $key => $nameTemp) {
$newName = $nameTemp[1] . '#' . $cloneIdx;
$nameTemps[$key] = [$nameTemp[0], $newName];
$this->blocks[$newName][$nameTemp[0]][] = $newIdx;
}
$this->domIdxToName[$newIdx] = $nameTemps;
}
}
}
/**
* 更新扩展索引关联关系
*
* 将原始节点的 idxExtendIdxs 映射到克隆节点
*
* @param \DOMNode $originalNode 原始节点
* @param array $oldToNewMap 索引映射表
* @return void
*/
private function updateExtendIdxs($originalNode, $oldToNewMap)
{
$begin = $originalNode->idxBegin;
$end = $originalNode->idxEnd;
for ($i = $begin; $i <= $end; $i++) {
if (isset($this->idxExtendIdxs[$i])) {
$newIdx = $oldToNewMap[$i];
$this->idxExtendIdxs[$newIdx] = [];
foreach ($this->idxExtendIdxs[$i] as $oldRefId) {
if (isset($oldToNewMap[$oldRefId])) {
$this->idxExtendIdxs[$newIdx][] = $oldToNewMap[$oldRefId];
}
}
}
}
}
}