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
42 changes: 18 additions & 24 deletions Sources/OOXMLSwift/IO/DocumentWalker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ import Foundation
/// so callers can mark the correct part dirty in `WordDocument.modifiedParts`
/// instead of blanket-marking `word/document.xml`.
internal enum DocumentWalker {
private struct PartParagraphVisitor: BodyChildVisitor {
typealias State = String

let initialState: String
let visit: (Paragraph, _ partKey: String) -> Void

mutating func visitParagraph(_ paragraph: inout Paragraph, state: inout String) {
visit(paragraph, state)
}
}

// MARK: - Part-key constants

Expand Down Expand Up @@ -46,7 +56,7 @@ internal enum DocumentWalker {
/// key the paragraph lives in. Recurses into table cells, nested tables,
/// and block-level content-control children so callers see the same
/// universe regardless of where the paragraph is nested.
static func walkAllParagraphs(in document: WordDocument, visit: (Paragraph, _ partKey: String) -> Void) {
static func walkAllParagraphs(in document: WordDocument, visit: @escaping (Paragraph, _ partKey: String) -> Void) {
// Body
walkBodyChildren(document.body.children, partKey: bodyPartKey, visit: visit)
// Headers — v0.19.5+ (#56 R5 P0 #6): walk bodyChildren so paragraphs
Expand All @@ -70,29 +80,13 @@ internal enum DocumentWalker {
}
}

private static func walkBodyChildren(_ children: [BodyChild], partKey: String, visit: (Paragraph, _ partKey: String) -> Void) {
for child in children {
switch child {
case .paragraph(let p):
visit(p, partKey)
case .table(let t):
walkTable(t, partKey: partKey, visit: visit)
case .contentControl(_, let inner):
walkBodyChildren(inner, partKey: partKey, visit: visit)
case .bookmarkMarker, .rawBlockElement:
// Body-level markers contain no paragraphs to visit (#58).
continue
}
}
}

private static func walkTable(_ table: Table, partKey: String, visit: (Paragraph, _ partKey: String) -> Void) {
for row in table.rows {
for cell in row.cells {
for para in cell.paragraphs { visit(para, partKey) }
for nested in cell.nestedTables { walkTable(nested, partKey: partKey, visit: visit) }
}
}
private static func walkBodyChildren(
_ children: [BodyChild],
partKey: String,
visit: @escaping (Paragraph, _ partKey: String) -> Void
) {
var visitor = PartParagraphVisitor(initialState: partKey, visit: visit)
BodyChildWalker.walk(children, visitor: &visitor)
}

// MARK: - findUnrecognizedChild
Expand Down
120 changes: 120 additions & 0 deletions Sources/OOXMLSwift/Models/BodyChildVisitor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/// Shared recursive traversal for `BodyChild` trees.
///
/// The visitor makes recursion policy explicit at the call site: table cells,
/// nested tables, and block-level content controls can be enabled or skipped
/// independently. This is intentionally small so existing ad-hoc walkers can
/// migrate one at a time without changing their mutation semantics (#28).
protocol BodyChildVisitor {
associatedtype State

var initialState: State { get }
var recursesIntoTableCells: Bool { get }
var recursesIntoNestedTables: Bool { get }
var recursesIntoContentControls: Bool { get }

mutating func visitParagraph(_ paragraph: inout Paragraph, state: inout State)
mutating func visitSkippedTable(_ table: inout Table, state: inout State)
mutating func visitSkippedBodyChild(_ child: inout BodyChild, state: inout State)
}

extension BodyChildVisitor {
var recursesIntoTableCells: Bool { true }
var recursesIntoNestedTables: Bool { true }
var recursesIntoContentControls: Bool { true }

mutating func visitSkippedTable(_ table: inout Table, state: inout State) {}
mutating func visitSkippedBodyChild(_ child: inout BodyChild, state: inout State) {}
}

enum BodyChildWalker {
@discardableResult
static func walk<V: BodyChildVisitor>(
_ children: [BodyChild],
visitor: inout V
) -> V.State {
var mutableChildren = children
return walk(&mutableChildren, visitor: &visitor)
}

@discardableResult
static func walk<V: BodyChildVisitor>(
_ children: inout [BodyChild],
visitor: inout V
) -> V.State {
var state = visitor.initialState
walk(&children, visitor: &visitor, state: &state)
return state
}

static func walk<V: BodyChildVisitor>(
_ children: inout [BodyChild],
visitor: inout V,
state: inout V.State
) {
for index in children.indices {
walk(&children[index], visitor: &visitor, state: &state)
}
}

private static func walk<V: BodyChildVisitor>(
_ child: inout BodyChild,
visitor: inout V,
state: inout V.State
) {
switch child {
case .paragraph(var paragraph):
visitor.visitParagraph(&paragraph, state: &state)
child = .paragraph(paragraph)

case .table(var table):
if visitor.recursesIntoTableCells {
walkTable(&table, visitor: &visitor, state: &state)
child = .table(table)
} else {
visitor.visitSkippedTable(&table, state: &state)
child = .table(table)
}

case .contentControl(let control, var children):
if visitor.recursesIntoContentControls {
walk(&children, visitor: &visitor, state: &state)
}
child = .contentControl(control, children: children)

case .bookmarkMarker, .rawBlockElement:
visitor.visitSkippedBodyChild(&child, state: &state)
}
}

private static func walkTable<V: BodyChildVisitor>(
_ table: inout Table,
visitor: inout V,
state: inout V.State
) {
for rowIndex in table.rows.indices {
for cellIndex in table.rows[rowIndex].cells.indices {
for paragraphIndex in table.rows[rowIndex].cells[cellIndex].paragraphs.indices {
visitor.visitParagraph(
&table.rows[rowIndex].cells[cellIndex].paragraphs[paragraphIndex],
state: &state
)
}

for nestedIndex in table.rows[rowIndex].cells[cellIndex].nestedTables.indices {
if visitor.recursesIntoNestedTables {
walkTable(
&table.rows[rowIndex].cells[cellIndex].nestedTables[nestedIndex],
visitor: &visitor,
state: &state
)
} else {
visitor.visitSkippedTable(
&table.rows[rowIndex].cells[cellIndex].nestedTables[nestedIndex],
state: &state
)
}
}
}
}
}
}
37 changes: 37 additions & 0 deletions Tests/OOXMLSwiftTests/DocumentWalkerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,43 @@ final class DocumentWalkerTests: XCTestCase {
XCTAssertTrue(visited.contains("sdt-inner"), "SDT child paragraph not visited; got: \(visited)")
}

func testBodyChildWalkerHonorsExplicitRecursionPolicy() {
struct TextVisitor: BodyChildVisitor {
var initialState: [String] = []
var recursesIntoTableCells: Bool = false
var recursesIntoNestedTables: Bool = false
var recursesIntoContentControls: Bool = true

mutating func visitParagraph(_ paragraph: inout Paragraph, state: inout [String]) {
state.append(paragraph.runs.map { $0.text }.joined())
}

mutating func visitSkippedTable(_ table: inout Table, state: inout [String]) {
state.append("skipped-table")
}
}

var top = Paragraph()
top.runs = [Run(text: "top")]
var tablePara = Paragraph()
tablePara.runs = [Run(text: "table")]
var sdtPara = Paragraph()
sdtPara.runs = [Run(text: "sdt")]

let table = Table(rows: [TableRow(cells: [TableCell(paragraphs: [tablePara])])])
let contentControl = ContentControl.richText(tag: "T", alias: "A", content: "")
let children: [BodyChild] = [
.paragraph(top),
.table(table),
.contentControl(contentControl, children: [.paragraph(sdtPara)])
]

var visitor = TextVisitor()
let visited = BodyChildWalker.walk(children, visitor: &visitor)

XCTAssertEqual(visited, ["top", "skipped-table", "sdt"])
}

func testWalkVisitsHeaderParagraphsWithHeaderPartKey() {
var doc = WordDocument()
var h = Header(id: "rId10", paragraphs: [], type: .default, originalFileName: "header3.xml")
Expand Down