diff --git a/Sources/OOXMLSwift/IO/DocumentWalker.swift b/Sources/OOXMLSwift/IO/DocumentWalker.swift index a061406..fb12420 100644 --- a/Sources/OOXMLSwift/IO/DocumentWalker.swift +++ b/Sources/OOXMLSwift/IO/DocumentWalker.swift @@ -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 @@ -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 @@ -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 diff --git a/Sources/OOXMLSwift/Models/BodyChildVisitor.swift b/Sources/OOXMLSwift/Models/BodyChildVisitor.swift new file mode 100644 index 0000000..c04acd9 --- /dev/null +++ b/Sources/OOXMLSwift/Models/BodyChildVisitor.swift @@ -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( + _ children: [BodyChild], + visitor: inout V + ) -> V.State { + var mutableChildren = children + return walk(&mutableChildren, visitor: &visitor) + } + + @discardableResult + static func walk( + _ children: inout [BodyChild], + visitor: inout V + ) -> V.State { + var state = visitor.initialState + walk(&children, visitor: &visitor, state: &state) + return state + } + + static func walk( + _ 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( + _ child: inout BodyChild, + visitor: inout V, + state: inout V.State + ) { + switch child { + case .paragraph(var paragraph): + visitor.visitParagraph(¶graph, 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( + _ 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 + ) + } + } + } + } + } +} diff --git a/Tests/OOXMLSwiftTests/DocumentWalkerTests.swift b/Tests/OOXMLSwiftTests/DocumentWalkerTests.swift index fe74e1b..b3fc920 100644 --- a/Tests/OOXMLSwiftTests/DocumentWalkerTests.swift +++ b/Tests/OOXMLSwiftTests/DocumentWalkerTests.swift @@ -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")