From ca85a9d7b98940b58750c63e2a65592ab212d025 Mon Sep 17 00:00:00 2001 From: "d.svitak" Date: Mon, 14 Sep 2026 09:11:25 +0200 Subject: [PATCH 1/3] Docx parser --- gradle/libs.versions.toml | 5 + migration-examples/build.gradle.kts | 2 + .../migration/example/docx/DocxParse.groovy | 15 + .../example/docx/DocxParseWithApply.groovy | 17 + .../docx/parser/DocxAnchoredAreas.groovy | 232 +++++++ .../example/docx/parser/DocxBlocks.groovy | 36 ++ .../docx/parser/DocxBodyContent.groovy | 125 ++++ .../docx/parser/DocxFloatingTables.groovy | 54 ++ .../docx/parser/DocxHeaderFooters.groovy | 281 +++++++++ .../example/docx/parser/DocxHeadings.groovy | 65 ++ .../example/docx/parser/DocxImages.groovy | 115 ++++ .../docx/parser/DocxMergeFields.groovy | 585 ++++++++++++++++++ .../example/docx/parser/DocxPageLayout.groovy | 52 ++ .../docx/parser/DocxPageSections.groovy | 124 ++++ .../docx/parser/DocxParagraphParser.groovy | 133 ++++ .../example/docx/parser/DocxStatistics.groovy | 40 ++ .../docx/parser/DocxTableParser.groovy | 103 +++ .../docx/parser/DocxTemplateParser.groovy | 178 ++++++ .../docx/parser/DocxVariablePatterns.groovy | 90 +++ .../docx/report/DocxComplexityReport.groovy | 48 ++ .../docx/style/DocxParagraphStyles.groovy | 96 +++ .../docx/style/DocxTableStyling.groovy | 167 +++++ .../example/docx/style/DocxTextStyles.groovy | 56 ++ .../docx/style/StyleChainResolver.groovy | 133 ++++ .../example/docx/util/DocxUtils.groovy | 64 ++ .../docx-project-config-template.toml | 28 + 26 files changed, 2844 insertions(+) create mode 100644 migration-examples/src/main/groovy/com/quadient/migration/example/docx/DocxParse.groovy create mode 100644 migration-examples/src/main/groovy/com/quadient/migration/example/docx/DocxParseWithApply.groovy create mode 100644 migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxAnchoredAreas.groovy create mode 100644 migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxBlocks.groovy create mode 100644 migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxBodyContent.groovy create mode 100644 migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxFloatingTables.groovy create mode 100644 migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxHeaderFooters.groovy create mode 100644 migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxHeadings.groovy create mode 100644 migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxImages.groovy create mode 100644 migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxMergeFields.groovy create mode 100644 migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxPageLayout.groovy create mode 100644 migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxPageSections.groovy create mode 100644 migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxParagraphParser.groovy create mode 100644 migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxStatistics.groovy create mode 100644 migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxTableParser.groovy create mode 100644 migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxTemplateParser.groovy create mode 100644 migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxVariablePatterns.groovy create mode 100644 migration-examples/src/main/groovy/com/quadient/migration/example/docx/report/DocxComplexityReport.groovy create mode 100644 migration-examples/src/main/groovy/com/quadient/migration/example/docx/style/DocxParagraphStyles.groovy create mode 100644 migration-examples/src/main/groovy/com/quadient/migration/example/docx/style/DocxTableStyling.groovy create mode 100644 migration-examples/src/main/groovy/com/quadient/migration/example/docx/style/DocxTextStyles.groovy create mode 100644 migration-examples/src/main/groovy/com/quadient/migration/example/docx/style/StyleChainResolver.groovy create mode 100644 migration-examples/src/main/groovy/com/quadient/migration/example/docx/util/DocxUtils.groovy create mode 100644 migration-examples/src/main/resources/docx-project-config-template.toml diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 29705898..4c3634f3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,9 @@ koin-ktor = "4.2.2" ktor = "3.5.2" stately = "2.1.0" +# examples +poi-ooxml = "5.5.1" + # wfd-xml spock = "2.4-groovy-5.0" xmlunit = "2.13.0" @@ -104,6 +107,8 @@ groovy-json = { module = "org.apache.groovy:groovy-json", version.ref = "groovy" junit-bom = { module = "org.junit:junit-bom", version.ref = "junit-jupiter" } junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit-jupiter" } junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit-jupiter" } +poi-ooxml = { module = "org.apache.poi:poi-ooxml", version.ref = "poi-ooxml" } +poi-ooxml-full = { module = "org.apache.poi:poi-ooxml-full", version.ref = "poi-ooxml" } # wfd-xml spock-core = { module = "org.spockframework:spock-core", version.ref = "spock" } diff --git a/migration-examples/build.gradle.kts b/migration-examples/build.gradle.kts index fcac8f56..994e113f 100644 --- a/migration-examples/build.gradle.kts +++ b/migration-examples/build.gradle.kts @@ -45,6 +45,8 @@ dependencies { implementation(libs.groovy.json) implementation(libs.jackson.databind) implementation(libs.slf4j.api) + implementation(libs.poi.ooxml) + implementation(libs.poi.ooxml.full) testImplementation(platform(libs.junit.bom)) testImplementation(libs.junit.jupiter) diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/docx/DocxParse.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/DocxParse.groovy new file mode 100644 index 00000000..bb81c901 --- /dev/null +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/DocxParse.groovy @@ -0,0 +1,15 @@ +//! --- +//! displayName: Parse DOCX +//! category: Parser +//! description: Parses input DOCX files specified in the project settings, translates their contents into the migration model and stores the resulting objects in the database. Persisted mappings are not applied. +//! sourceFormat: DOCX +//! --- +package com.quadient.migration.example.docx + +import static com.quadient.migration.example.common.util.InitMigration.initMigration +import static com.quadient.migration.example.docx.parser.DocxTemplateParser.parseDocxFiles + +def migration = initMigration(this.binding) + +println("\nStarting Parse step...\n") +parseDocxFiles(migration) diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/docx/DocxParseWithApply.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/DocxParseWithApply.groovy new file mode 100644 index 00000000..5bcfe12d --- /dev/null +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/DocxParseWithApply.groovy @@ -0,0 +1,17 @@ +//! --- +//! displayName: Parse DOCX and Apply Mapping +//! category: Parser +//! description: Parses input DOCX files specified in the project settings, translates their contents into the migration model, stores the resulting objects in the database and applies persisted mappings. +//! sourceFormat: DOCX +//! --- +package com.quadient.migration.example.docx + +import static com.quadient.migration.example.common.util.InitMigration.initMigration +import static com.quadient.migration.example.docx.parser.DocxTemplateParser.parseDocxFiles + +def migration = initMigration(this.binding) + +println("\nStarting Parse step...\n") +parseDocxFiles(migration) +println("\nApplying persisted mapping...\n") +migration.mappingRepository.applyAll() diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxAnchoredAreas.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxAnchoredAreas.groovy new file mode 100644 index 00000000..214f1600 --- /dev/null +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxAnchoredAreas.groovy @@ -0,0 +1,232 @@ +package com.quadient.migration.example.docx.parser + +import com.quadient.migration.api.Migration +import com.quadient.migration.api.dto.migrationmodel.Area +import com.quadient.migration.api.dto.migrationmodel.DocumentContent +import com.quadient.migration.api.dto.migrationmodel.DocumentObjectRef +import com.quadient.migration.api.dto.migrationmodel.builder.documentcontent.AreaBuilder +import com.quadient.migration.shared.ImageOptions +import com.quadient.migration.shared.Position +import com.quadient.migration.shared.Size +import org.apache.poi.xwpf.usermodel.XWPFDocument +import org.apache.poi.xwpf.usermodel.XWPFParagraph +import org.apache.poi.xwpf.usermodel.XWPFPictureData +import org.apache.poi.xwpf.usermodel.XWPFRun +import org.apache.xmlbeans.XmlCursor +import org.apache.xmlbeans.XmlObject +import org.openxmlformats.schemas.drawingml.x2006.picture.CTPicture +import org.openxmlformats.schemas.drawingml.x2006.wordprocessingDrawing.CTAnchor +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTP +import org.w3c.dom.Node +import org.xml.sax.InputSource + +import javax.xml.parsers.DocumentBuilder +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.transform.OutputKeys +import javax.xml.transform.Transformer +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult + +import static com.quadient.migration.example.docx.parser.DocxBlocks.blockName +import static com.quadient.migration.example.docx.parser.DocxBlocks.upsertBlock +import static com.quadient.migration.example.docx.parser.DocxImages.registerImageData +import static com.quadient.migration.example.docx.parser.DocxParagraphParser.parseParagraph +import static com.quadient.migration.example.docx.util.DocxUtils.emuToSize +import static com.quadient.migration.example.docx.util.DocxUtils.extractParagraphText + +class DocxAnchoredAreas { + private static final String WP_NS = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" + private static final String W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + private static final String WPS_SHAPE_URI = "http://schemas.microsoft.com/office/word/2010/wordprocessingShape" + private static final String PIC_NS = "http://schemas.openxmlformats.org/drawingml/2006/picture" + private static final double BACKGROUND_COVERAGE_THRESHOLD = 0.6 + + final Map> backgroundAreasByPage = [:] + final Map> floatingAreasByPage = [:] + final Set consumedEmbedIds = [] + private final Set backgroundEmbedIds = [] + private int textBoxes = 0 + + private final Migration migration + private final XWPFDocument doc + private final String fileName + private final DocumentBuilder documentBuilder + private final TransformerFactory transformerFactory = TransformerFactory.newInstance() + + private DocxAnchoredAreas(Migration migration, XWPFDocument doc, String fileName) { + this.migration = migration + this.doc = doc + this.fileName = fileName + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance() + dbf.namespaceAware = true + this.documentBuilder = dbf.newDocumentBuilder() + } + + static DocxAnchoredAreas extract(Migration migration, XWPFDocument doc, String fileName, List pages) { + DocxAnchoredAreas result = new DocxAnchoredAreas(migration, doc, fileName) + // Backgrounds are resolved over the whole document first so the same picture is never emitted as floating too. + pages.each { DocxPage page -> result.eachUniqueAnchor(page) { result.addBackgroundArea(it, page) } } + pages.each { DocxPage page -> result.eachUniqueAnchor(page) { result.addFloatingArea(it, page) } } + return result + } + + private void eachUniqueAnchor(DocxPage page, Closure handler) { + Set seenDrawingIds = [] + page.paragraphs().each { XWPFParagraph paragraph -> + paragraph.runs.each { XWPFRun run -> + findAnchors(run).each { CTAnchor anchor -> + if (seenDrawingIds.add(anchor.docPr?.id ?: anchor.xmlText())) { + handler(anchor) + } + } + } + } + } + + private void addBackgroundArea(CTAnchor anchor, DocxPage page) { + if (!isPicture(anchor) || !coversPage(anchor, page)) { + return + } + String embedId = extractBlipEmbedId(anchor) + Position position = new Position(Size.ofPoints(0), Size.ofPoints(0), page.width, page.height) + Area area = embedId ? buildImageArea(anchor, embedId, position) : null + if (area != null) { + backgroundAreasByPage.computeIfAbsent(page.index) { [] }.add(area) + backgroundEmbedIds.add(embedId) + consumedEmbedIds.add(embedId) + } + } + + private void addFloatingArea(CTAnchor anchor, DocxPage page) { + Area area = null + if (anchor.graphic?.graphicData?.uri == WPS_SHAPE_URI) { + area = buildTextBoxArea(anchor, page) + } else if (isPicture(anchor)) { + String embedId = extractBlipEmbedId(anchor) + if (embedId && !backgroundEmbedIds.contains(embedId)) { + area = buildImageArea(anchor, embedId, resolveAnchorPosition(anchor, page)) + if (area != null) { + consumedEmbedIds.add(embedId) + } + } + } + if (area != null) { + floatingAreasByPage.computeIfAbsent(page.index) { [] }.add(area) + } + } + + private static boolean isPicture(CTAnchor anchor) { + return anchor.graphic?.graphicData?.uri == PIC_NS + } + + private static boolean coversPage(CTAnchor anchor, DocxPage page) { + Size width = emuToSize(anchor.extent?.cx ?: 0L) + Size height = emuToSize(anchor.extent?.cy ?: 0L) + return width.toPoints() >= page.width.toPoints() * BACKGROUND_COVERAGE_THRESHOLD + && height.toPoints() >= page.height.toPoints() * BACKGROUND_COVERAGE_THRESHOLD + } + + private static List findAnchors(XWPFRun run) { + XmlObject[] found = run.CTR.selectPath("declare namespace wp='${WP_NS}' .//wp:anchor") + return found.collect { XmlObject o -> o instanceof CTAnchor ? o : CTAnchor.Factory.parse(o.xmlText()) } + } + + private static String extractBlipEmbedId(CTAnchor anchor) { + XmlObject[] pics = anchor.selectPath("declare namespace pic='${PIC_NS}' .//pic:pic") + if (pics.length == 0) { + return null + } + CTPicture pic = pics[0] instanceof CTPicture ? pics[0] as CTPicture : CTPicture.Factory.parse(pics[0].toString()) + return pic.blipFill?.blip?.embed + } + + private Area buildImageArea(CTAnchor anchor, String embedId, Position position) { + XWPFPictureData data = doc.getPictureDataByID(embedId) + if (data == null) { + return null + } + Size width = emuToSize(anchor.extent?.cx ?: 0L) + Size height = emuToSize(anchor.extent?.cy ?: 0L) + String imageId = registerImageData(migration, data, fileName, new ImageOptions(width, height)) + if (imageId == null) { + return null + } + return new AreaBuilder().imageRef(imageId).position(position).build() + } + + private Area buildTextBoxArea(CTAnchor anchor, DocxPage page) { + XmlCursor cursor = anchor.graphic.graphicData.newCursor() + try { + if (!cursor.toFirstChild()) { + return null + } + def shapeDom = documentBuilder.parse(new InputSource(new StringReader(cursor.xmlText()))) + def txbxContentNodes = shapeDom.getElementsByTagNameNS(W_NS, "txbxContent") + if (txbxContentNodes.length == 0) { + return null + } + List contentItems = [] + def children = txbxContentNodes.item(0).childNodes + for (int i = 0; i < children.length; i++) { + Node child = children.item(i) + if (child.nodeType == Node.ELEMENT_NODE && child.localName == 'p') { + XWPFParagraph paragraph = new XWPFParagraph(domParagraphToCtp(child), doc) + contentItems.add(parseParagraph(migration, paragraph, fileName)) + } + } + if (contentItems.isEmpty()) { + return null + } + String blockId = "${page.id(fileName)}_textbox${++textBoxes}" + String firstText = contentItems.findResult { extractParagraphText(it)?.trim() ?: null } + DocumentObjectRef blockRef = upsertBlock(migration, blockId, blockName(firstText, "text box", blockId), contentItems, fileName) + return new AreaBuilder().content([blockRef]).position(resolveAnchorPosition(anchor, page)).build() + } finally { + cursor.dispose() + } + } + + private CTP domParagraphToCtp(Node pNode) { + StringWriter sw = new StringWriter() + Transformer transformer = transformerFactory.newTransformer() + transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes") + transformer.transform(new DOMSource(pNode), new StreamResult(sw)) + String fragmentXml = sw.toString().replaceFirst(/^$/, '') + return CTP.Factory.parse(fragmentXml) + } + + static Position resolveAnchorPosition(CTAnchor anchor, DocxPage page) { + Size extentWidth = emuToSize(anchor.extent?.cx ?: 0L) + Size extentHeight = emuToSize(anchor.extent?.cy ?: 0L) + double marginLeft = page.contentPosition.x.toPoints() + double marginTop = page.contentPosition.y.toPoints() + boolean relativeToPageH = anchor.positionH?.relativeFrom?.toString() == "page" + boolean relativeToPageV = anchor.positionV?.relativeFrom?.toString() == "page" + + double x + if (anchor.positionH?.isSetPosOffset()) { + x = emuToSize(anchor.positionH.posOffset).toPoints() + (relativeToPageH ? 0.0d : marginLeft) + } else { + double leftBound = relativeToPageH ? 0.0d : marginLeft + double rightBound = relativeToPageH ? page.width.toPoints() : marginLeft + page.contentPosition.width.toPoints() + switch (anchor.positionH?.align?.toString()) { + case "right": + case "outside": + x = rightBound - extentWidth.toPoints() + break + case "center": + x = leftBound + (rightBound - leftBound - extentWidth.toPoints()) / 2.0d + break + default: + x = leftBound + } + } + + double y = anchor.positionV?.isSetPosOffset() + ? emuToSize(anchor.positionV.posOffset).toPoints() + (relativeToPageV ? 0.0d : marginTop) + : marginTop + + return new Position(Size.ofPoints(x), Size.ofPoints(y), extentWidth, extentHeight) + } +} diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxBlocks.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxBlocks.groovy new file mode 100644 index 00000000..3810b759 --- /dev/null +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxBlocks.groovy @@ -0,0 +1,36 @@ +package com.quadient.migration.example.docx.parser + +import com.quadient.migration.api.Migration +import com.quadient.migration.api.dto.migrationmodel.DisplayRuleRef +import com.quadient.migration.api.dto.migrationmodel.DocumentContent +import com.quadient.migration.api.dto.migrationmodel.DocumentObjectRef +import com.quadient.migration.api.dto.migrationmodel.builder.DocumentObjectBuilder +import com.quadient.migration.shared.DocumentObjectType +import groovy.transform.Field + +@Field +static final int MAX_BLOCK_NAME_LENGTH = 60 + +// Stores a piece of content as an internal block and returns the reference to place in the flow instead. +static DocumentObjectRef upsertBlock(Migration migration, String id, String name, List content, String fileName, + String displayRuleId = null) { + migration.documentObjectRepository.upsert(new DocumentObjectBuilder(id, DocumentObjectType.Block) + .name(name) + .content(content) + .internal(true) + .originLocations([fileName]) + .build()) + return new DocumentObjectRef(id, displayRuleId ? new DisplayRuleRef(displayRuleId) : null) +} + +// Derives a readable block name from source text (a heading, a table's first cell, ...), shortened at a word boundary. +static String blockName(String text, String suffix, String fallback) { + String normalized = text?.replaceAll(/\s+/, " ")?.trim() + if (!normalized) { + return fallback + } + if (normalized.length() > MAX_BLOCK_NAME_LENGTH) { + normalized = normalized.substring(0, MAX_BLOCK_NAME_LENGTH).replaceFirst(/\s+\S*$/, "") + } + return suffix ? "${normalized} ${suffix}" : normalized +} diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxBodyContent.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxBodyContent.groovy new file mode 100644 index 00000000..9cf22b3d --- /dev/null +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxBodyContent.groovy @@ -0,0 +1,125 @@ +package com.quadient.migration.example.docx.parser + +import com.quadient.migration.api.Migration +import com.quadient.migration.api.dto.migrationmodel.DocumentContent +import com.quadient.migration.api.dto.migrationmodel.builder.TableBuilder +import org.apache.poi.xwpf.usermodel.XWPFTable +import org.apache.poi.xwpf.usermodel.XWPFTableCell +import org.apache.xmlbeans.XmlObject + +import static com.quadient.migration.example.docx.parser.DocxBlocks.blockName +import static com.quadient.migration.example.docx.parser.DocxBlocks.upsertBlock +import static com.quadient.migration.example.docx.parser.DocxTableParser.addRows +import static com.quadient.migration.example.docx.parser.DocxTableParser.createTableBuilder +import static com.quadient.migration.example.docx.parser.DocxTableParser.haveSameGrid +import static com.quadient.migration.example.docx.parser.DocxTableParser.resolveExpectedColumnCount +import static com.quadient.migration.example.docx.util.DocxUtils.extractParagraphText + +/** + * Collects the body content of one page. The most recent table is kept open (unbuilt) so that a following table wrapped + * in an IF field can be appended to it as conditional rows, which is how Word templates model optional table rows. + * A conditional table that does not fit the preceding one is wrapped in an internal block carrying the display rule. + * Headings are remembered so that the finished page can be divided into one block per top-level section. + */ +class DocxBodyContent { + private final Migration migration + private final String fileName + private final String pageId + private final List content = [] + // Heading level of the content items that are headings, keyed by item identity (model items are value objects). + private final Map headingLevels = new IdentityHashMap<>() + + private TableBuilder openTable + private XWPFTable openTableSource + private int openTableColumnCount + private int conditionalTableBlocks = 0 + + DocxBodyContent(Migration migration, String fileName, String pageId) { + this.migration = migration + this.fileName = fileName + this.pageId = pageId + } + + void add(DocumentContent item, Integer headingLevel = null) { + closeTable() + content.add(item) + if (headingLevel != null) { + headingLevels.put(item, headingLevel) + } + } + + void addTable(XWPFTable table, TableBuilder tableBuilder, int columnCount) { + closeTable() + openTable = tableBuilder + openTableSource = table + openTableColumnCount = columnCount + } + + // Handler for tables wrapped in an IF field. Its rows join a directly preceding regular table with the same grid + // (optional rows of one table); otherwise the whole table is conditional, so it is wrapped in an internal block + // referenced with the display rule, which keeps the table itself free of per-row rules. + void addConditionalTable(XWPFTable table, String displayRuleId) { + int columnCount = resolveExpectedColumnCount(table) + if (openTable != null && openTableColumnCount == columnCount && haveSameGrid(openTableSource, table)) { + addRows(migration, openTable, table, table.rows, fileName, columnCount, false, displayRuleId) + return + } + TableBuilder tableBuilder = createTableBuilder(migration, table, true) + addRows(migration, tableBuilder, table, table.rows, fileName, columnCount, true) + String id = "${pageId}_table${++conditionalTableBlocks}" + add(upsertBlock(migration, id, blockName(firstCellText(table), "table", id), [tableBuilder.build()], fileName, displayRuleId)) + } + + void closeTable() { + if (openTable != null) { + content.add(openTable.build()) + openTable = null + openTableSource = null + } + } + + // The collected content as is, without dividing it into sections. + List flow() { + closeTable() + return content + } + + /** + * Divides the page content into internal blocks, one per top-level heading (the lowest heading level found on the + * page); content before the first heading forms a block of its own. Pages without headings stay a plain flow. + */ + List sectionBlocks() { + closeTable() + Integer topLevel = headingLevels.values().min() + if (topLevel == null) { + return content + } + List> sections = [[]] + content.each { DocumentContent item -> + if (headingLevels[item] == topLevel && !sections.last().isEmpty()) { + sections << [] + } + sections.last() << item + } + return sections.withIndex().collect { List items, int i -> + String id = "${pageId}_section${i + 1}" + String heading = headingLevels[items[0]] == topLevel ? extractParagraphText(items[0]) : null + upsertBlock(migration, id, blockName(heading, null, id), items, fileName) + } + } + + // Text of the first non-empty cell of the table's first row (typically the heading of the row). Word stores the + // text of a table wrapped in a field as instrText, which POI's text accessors skip. + private static String firstCellText(XWPFTable table) { + return table.rows[0].tableCells.collect { cellText(it).trim() }.find { it } + } + + private static String cellText(XWPFTableCell cell) { + return cell.paragraphs.collect { paragraph -> + paragraph.runs.collect { run -> + List fieldChildren = DocxMergeFields.fieldChildren(run) + fieldChildren.isEmpty() ? run.text() : DocxMergeFields.instructionText(fieldChildren) + }.join("") + }.join(" ") + } +} diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxFloatingTables.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxFloatingTables.groovy new file mode 100644 index 00000000..cb596a92 --- /dev/null +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxFloatingTables.groovy @@ -0,0 +1,54 @@ +package com.quadient.migration.example.docx.parser + +import com.quadient.migration.api.dto.migrationmodel.Area +import com.quadient.migration.api.dto.migrationmodel.DocumentContent +import com.quadient.migration.api.dto.migrationmodel.builder.documentcontent.AreaBuilder +import com.quadient.migration.shared.Position +import com.quadient.migration.shared.Size +import org.apache.poi.xwpf.usermodel.XWPFTable +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblPPr + +import static com.quadient.migration.example.docx.util.DocxUtils.twipsToPoints + +class DocxFloatingTables { + private static final double MIN_AREA_HEIGHT_PT = 10.0d + + static boolean isFloating(XWPFTable table) { + return table.CTTbl?.tblPr?.tblpPr != null + } + + static Area buildFloatingTableArea(XWPFTable table, List contentItems, Position pagePosition) { + if (contentItems.isEmpty()) { + return null + } + Position position = resolveTablePosition(table, table.CTTbl.tblPr.tblpPr, pagePosition) + return new AreaBuilder().content(contentItems).position(position).build() + } + + private static Position resolveTablePosition(XWPFTable table, CTTblPPr tblpPr, Position pagePosition) { + double marginLeft = pagePosition.x.toPoints() + double marginTop = pagePosition.y.toPoints() + + double x = marginLeft + if (tblpPr.isSetTblpX()) { + x = twipsToPoints(tblpPr.tblpX) + (tblpPr.horzAnchor?.toString() == "page" ? 0.0d : marginLeft) + } + double y = marginTop + if (tblpPr.isSetTblpY()) { + y = twipsToPoints(tblpPr.tblpY) + (tblpPr.vertAnchor?.toString() == "page" ? 0.0d : marginTop) + } + + double contentBottom = marginTop + pagePosition.height.toPoints() + double height = Math.max(contentBottom - y, MIN_AREA_HEIGHT_PT) + return new Position(Size.ofPoints(x), Size.ofPoints(y), resolveTableWidth(table, pagePosition.width), Size.ofPoints(height)) + } + + private static Size resolveTableWidth(XWPFTable table, Size fallbackWidth) { + def gridCols = table.CTTbl.tblGrid?.gridColList + if (!gridCols) { + return fallbackWidth + } + double totalPoints = gridCols.sum { twipsToPoints(it.w) ?: 0.0d } as double + return totalPoints > 0 ? Size.ofPoints(totalPoints) : fallbackWidth + } +} diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxHeaderFooters.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxHeaderFooters.groovy new file mode 100644 index 00000000..72523cc3 --- /dev/null +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxHeaderFooters.groovy @@ -0,0 +1,281 @@ +package com.quadient.migration.example.docx.parser + +import com.quadient.migration.api.Migration +import com.quadient.migration.api.dto.migrationmodel.Area +import com.quadient.migration.api.dto.migrationmodel.DocumentContent +import com.quadient.migration.api.dto.migrationmodel.Paragraph +import com.quadient.migration.api.dto.migrationmodel.Table +import com.quadient.migration.api.dto.migrationmodel.builder.documentcontent.AreaBuilder +import com.quadient.migration.shared.ImageOptions +import com.quadient.migration.shared.Position +import com.quadient.migration.shared.Size +import org.apache.poi.xwpf.usermodel.IBodyElement +import org.apache.poi.xwpf.usermodel.XWPFDocument +import org.apache.poi.xwpf.usermodel.XWPFHeaderFooter +import org.apache.poi.xwpf.usermodel.XWPFParagraph +import org.apache.poi.xwpf.usermodel.XWPFTable +import org.apache.poi.xwpf.usermodel.XWPFRun +import org.apache.poi.xwpf.usermodel.XWPFPictureData +import org.apache.xmlbeans.XmlObject +import org.openxmlformats.schemas.drawingml.x2006.picture.CTPicture +import org.openxmlformats.schemas.drawingml.x2006.wordprocessingDrawing.CTAnchor +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSectPr + +import static com.quadient.migration.example.docx.parser.DocxParagraphParser.parseFlowParagraph +import static com.quadient.migration.example.docx.parser.DocxParagraphParser.warnUnterminatedField +import static com.quadient.migration.example.docx.parser.DocxTableParser.parseTable +import static com.quadient.migration.example.docx.parser.DocxImages.registerImageData +import static com.quadient.migration.example.docx.util.DocxUtils.emuToSize +import static com.quadient.migration.example.docx.util.DocxUtils.twipsToPoints + +class DocxHeaderFooters { + private static final List TYPES = ['default', 'first', 'even'] + private static final String WP_NS = 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing' + private static final String PIC_NS = 'http://schemas.openxmlformats.org/drawingml/2006/picture' + + static List areas(Migration migration, XWPFDocument doc, DocxPage page, String fileName) { + return headerAreas(migration, doc, page, fileName) + footerAreas(migration, doc, page, fileName) + } + + static List headerAreas(Migration migration, XWPFDocument doc, DocxPage page, String fileName) { + return partAreas(migration, doc, page, fileName, true) + } + + static List footerAreas(Migration migration, XWPFDocument doc, DocxPage page, String fileName) { + return partAreas(migration, doc, page, fileName, false) + } + + private static List partAreas(Migration migration, XWPFDocument doc, DocxPage page, String fileName, boolean headerOnly) { + if (page.sections.isEmpty()) { + return [] + } + Map> parts = effectiveParts(doc, page.sections.first().sectPr) + List result = [] + XWPFHeaderFooter part = selectPart(headerOnly ? parts.headers : parts.footers, page) + if (part != null) { + Position position = headerOnly ? headerPosition(page) : footerPosition(page) + Set directImageEmbedIds = anchoredEmbedIds(part) + inlineEmbedIds(part) + Area area = buildArea(migration, part, fileName, position, directImageEmbedIds) + if (area != null) result.add(area) + result.addAll(inlineImageAreas(migration, doc, part, fileName, position)) + result.addAll(anchoredImageAreas(migration, doc, part, fileName, page, position)) + } + return result + } + + private static Map> effectiveParts(XWPFDocument doc, CTSectPr sectPr) { + Map> result = [headers: [:], footers: [:]] + if (sectPr == null) return result + Map headers = doc.headerList.collectEntries { [(it.packagePart.partName.name): it] } + Map footers = doc.footerList.collectEntries { [(it.packagePart.partName.name): it] } + resolveReferences(doc, sectPr.headerReferenceList, headers, result.headers) + resolveReferences(doc, sectPr.footerReferenceList, footers, result.footers) + return result + } + + private static void resolveReferences(XWPFDocument doc, List references, Map source, + Map target) { + references.each { reference -> + def relationship = doc.packagePart.getRelationship(reference.id) + XWPFHeaderFooter part = relationship == null ? null : source[relationship.targetURI.path] + if (part != null) { + target[reference.type?.toString() ?: 'default'] = part + } + } + } + + private static XWPFHeaderFooter selectPart(Map parts, DocxPage page) { + String type = page.index == 0 && page.sections.first().sectPr?.titlePg != null ? 'first' : + page.index % 2 == 1 ? 'even' : 'default' + // Word falls back to the default part when an even/first-page part is absent. + return parts[type] ?: parts['default'] + } + + private static Area buildArea(Migration migration, XWPFHeaderFooter source, String fileName, Position position, + Set excludedImageEmbedIds = Collections.emptySet()) { + List content = [] + FieldParseState fieldState = new FieldParseState() + source.bodyElements.each { IBodyElement element -> + if (element instanceof XWPFParagraph) { + Paragraph paragraph = parseFlowParagraph(migration, element, fileName, fieldState, 'headerFooter', excludedImageEmbedIds) + if (paragraph?.content) content.add(paragraph) + } else if (element instanceof XWPFTable) { + Table table = parseTable(migration, element, fileName) + if (table != null) content.add(table) + } + } + warnUnterminatedField(fieldState, 'header/footer') + return content.isEmpty() ? null : new AreaBuilder().content(content).position(position).build() + } + + // Anchored drawings are not exposed by XWPFRun.embeddedPictures. Header/footer parts own their image + // relationships, so resolve the blip through that part rather than the document's relationship table. + private static List anchoredImageAreas(Migration migration, XWPFDocument doc, XWPFHeaderFooter source, String fileName, + DocxPage page, Position flowPosition) { + List areas = [] + source.paragraphs.each { XWPFParagraph paragraph -> + paragraph.runs.each { XWPFRun run -> + findAnchors(run).each { CTAnchor anchor -> + if (anchor.graphic?.graphicData?.uri != PIC_NS) return + String embedId = extractBlipEmbedId(anchor) + XWPFPictureData data = pictureData(doc, source, embedId) + if (data == null) return + Size width = emuToSize(anchor.extent?.cx ?: 0L) + Size height = emuToSize(anchor.extent?.cy ?: 0L) + String imageId = registerImageData(migration, data, fileName, new ImageOptions(width, height)) + if (imageId != null) { + areas.add(new AreaBuilder().imageRef(imageId) + .position(resolveHeaderFooterAnchorPosition(anchor, page, flowPosition)).build()) + } + } + } + } + return areas + } + + // Header/footer images are fixed page elements. Emit both POI-bound and unbound inline pictures as areas so + // their dimensions are not constrained by the comparatively small header/footer flow frame. + private static List inlineImageAreas(Migration migration, XWPFDocument doc, XWPFHeaderFooter source, String fileName, + Position flowPosition) { + List areas = [] + source.paragraphs.each { XWPFParagraph paragraph -> + paragraph.runs.each { XWPFRun run -> + run.embeddedPictures.each { picture -> + CTPicture ctPicture = picture.CTPicture + XWPFPictureData data = picture.pictureData ?: pictureData(doc, source, ctPicture?.blipFill?.blip?.embed) + addInlineImageArea(areas, migration, data, ctPicture, fileName, flowPosition) + } + if (!run.embeddedPictures.isEmpty()) return + findInlines(run).each { XmlObject inline -> + CTPicture picture = inlinePicture(inline) + addInlineImageArea(areas, migration, pictureData(doc, source, picture?.blipFill?.blip?.embed), picture, fileName, flowPosition) + } + } + } + // Last-resort handling for a header/footer part whose inline drawing is not surfaced by POI's run API. + // It is safe only when the document contains exactly one image, which is the package shape of 01CVRPG0222. + if (areas.isEmpty() && doc.allPictures.size() == 1 && source.paragraphs.any { paragraph -> + paragraph.runs.any { it.CTR.xmlText().contains(' findAnchors(XWPFRun run) { + XmlObject[] found = run.CTR.selectPath("declare namespace wp='${WP_NS}' .//wp:anchor") + return found.collect { XmlObject o -> o instanceof CTAnchor ? o : CTAnchor.Factory.parse(o.xmlText()) } + } + + private static Set anchoredEmbedIds(XWPFHeaderFooter source) { + Set ids = [] + source.paragraphs.each { XWPFParagraph paragraph -> + paragraph.runs.each { XWPFRun run -> + findAnchors(run).each { CTAnchor anchor -> + String embedId = extractBlipEmbedId(anchor) + if (embedId) ids.add(embedId) + } + } + } + return ids + } + + private static Set inlineEmbedIds(XWPFHeaderFooter source) { + Set ids = [] + source.paragraphs.each { XWPFParagraph paragraph -> + paragraph.runs.each { XWPFRun run -> + run.embeddedPictures.each { picture -> + String embedId = picture.CTPicture?.blipFill?.blip?.embed + if (embedId) ids.add(embedId) + } + findInlines(run).each { XmlObject inline -> + String embedId = inlinePicture(inline)?.blipFill?.blip?.embed + if (embedId) ids.add(embedId) + } + } + } + return ids + } + + private static void addInlineImageArea(List areas, Migration migration, XWPFPictureData data, CTPicture picture, + String fileName, Position flowPosition) { + if (data == null || picture == null) return + Size width = emuToSize(picture.spPr?.xfrm?.ext?.cx ?: 0L) + Size height = emuToSize(picture.spPr?.xfrm?.ext?.cy ?: 0L) + String imageId = registerImageData(migration, data, fileName, new ImageOptions(width, height)) + if (imageId != null) { + areas.add(new AreaBuilder().imageRef(imageId) + .position(new Position(flowPosition.x, flowPosition.y, width, height)).build()) + } + } + + private static List findInlines(XWPFRun run) { + return run.CTR.selectPath("declare namespace wp='${WP_NS}' .//wp:inline") as List + } + + private static CTPicture inlinePicture(XmlObject inline) { + XmlObject[] pictures = inline.selectPath("declare namespace pic='${PIC_NS}' .//pic:pic") + return pictures.length == 0 ? null : (pictures[0] instanceof CTPicture + ? pictures[0] as CTPicture : CTPicture.Factory.parse(pictures[0].xmlText())) + } + + // Header/footer anchors can be relative to their paragraph or column. Unlike body content, those origins sit + // in the header/footer frame, not at the main body margin. + private static Position resolveHeaderFooterAnchorPosition(CTAnchor anchor, DocxPage page, Position flowPosition) { + Size width = emuToSize(anchor.extent?.cx ?: 0L) + Size height = emuToSize(anchor.extent?.cy ?: 0L) + boolean relativeToPageH = anchor.positionH?.relativeFrom?.toString() == 'page' + boolean relativeToPageV = anchor.positionV?.relativeFrom?.toString() == 'page' + double x = relativeToPageH ? 0d : flowPosition.x.toPoints() + double y = relativeToPageV ? 0d : flowPosition.y.toPoints() + if (anchor.positionH?.isSetPosOffset()) x += emuToSize(anchor.positionH.posOffset).toPoints() + if (anchor.positionV?.isSetPosOffset()) y += emuToSize(anchor.positionV.posOffset).toPoints() + return new Position(Size.ofPoints(x), Size.ofPoints(y), width, height) + } + + private static XWPFPictureData pictureData(XWPFDocument doc, XWPFHeaderFooter source, String embedId) { + if (!embedId) return null + XWPFPictureData data = source.getPictureDataByID(embedId) + if (data != null) return data + def relatedPart = source.getRelationById(embedId) + if (relatedPart instanceof XWPFPictureData) return relatedPart + def relationship = source.packagePart.getRelationship(embedId) + data = relationship == null ? null : source.allPictures.find { + it.packagePart.partName.name == relationship.targetURI.path + } + // Some DOCX producers leave POI unable to bind a header relationship even though the document has a + // single embedded image. This fallback covers that unambiguous package shape (including 01CVRPG0222). + return data ?: (doc.allPictures.size() == 1 ? doc.allPictures[0] : null) + } + + private static String extractBlipEmbedId(CTAnchor anchor) { + XmlObject[] pictures = anchor.selectPath("declare namespace pic='${PIC_NS}' .//pic:pic") + if (pictures.length == 0) return null + CTPicture picture = pictures[0] instanceof CTPicture ? pictures[0] as CTPicture : CTPicture.Factory.parse(pictures[0].xmlText()) + return picture.blipFill?.blip?.embed + } + + private static Position headerPosition(DocxPage page) { + double contentTop = page.contentPosition.y.toPoints() + double headerOffset = headerFooterOffset(page, 'header', 0d) + double y = Math.min(Math.max(headerOffset, 0d), contentTop) + return new Position(page.contentPosition.x, Size.ofPoints(y), page.contentPosition.width, + Size.ofPoints(Math.max(contentTop - y, 1d))) + } + + private static Position footerPosition(DocxPage page) { + double y = page.contentPosition.y.toPoints() + page.contentPosition.height.toPoints() + double height = page.height.toPoints() - y + return new Position(page.contentPosition.x, Size.ofPoints(y), page.contentPosition.width, Size.ofPoints(Math.max(height, 1d))) + } + + private static double headerFooterOffset(DocxPage page, String property, double fallback) { + def margins = page.sections.first()?.sectPr?.pgMar + Double offset = twipsToPoints(margins?."${property}") + return offset != null ? offset : fallback + } +} diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxHeadings.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxHeadings.groovy new file mode 100644 index 00000000..ac8c041f --- /dev/null +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxHeadings.groovy @@ -0,0 +1,65 @@ +package com.quadient.migration.example.docx.parser + +import groovy.transform.Field +import org.apache.poi.xwpf.usermodel.XWPFParagraph +import org.apache.poi.xwpf.usermodel.XWPFStyle +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTStyle + +import java.util.regex.Matcher +import java.util.regex.Pattern + +// Word's outline levels are 0-based and level 9 means "body text". +@Field +static final int BODY_TEXT_OUTLINE_LEVEL = 9 +@Field +static final Pattern HEADING_STYLE_NAME = ~/(?i)(heading|hdr|title)\s*(\d)?/ +// Styles whose name says they are body text are never headings, even when they are (carelessly) based on a heading. +@Field +static final Pattern BODY_STYLE_NAME = ~/(?i)body|normal|text|list|table|toc|caption|footer|header/ + +/** + * Returns the 1-based heading level of a paragraph, or null when it is ordinary text. The paragraph's style chain is + * walked from the most specific style upwards: a heading-like style name decides first (e.g. "SLR HDR2" or "1. SLR + * HDR1", which have no outline level of their own), then a style's outline level. Outline levels set directly on a + * paragraph are ignored, as they typically stem from copied formatting rather than from document structure. + */ +static Integer headingLevel(XWPFParagraph paragraph) { + if (paragraph.text.isBlank()) { + return null + } + Set visited = [] + String styleId = paragraph.styleID + while (styleId && visited.add(styleId)) { + XWPFStyle style = paragraph.document.styles?.getStyle(styleId) + if (style == null) { + return null + } + Integer level = levelOf(style) + if (level != null) { + return level > 0 ? level : null + } + CTStyle ctStyle = style.CTStyle + styleId = ctStyle.isSetBasedOn() ? ctStyle.basedOn.val : null + } + return null +} + +// Heading level of one style; 0 means "definitely body text", null means "undecided, ask the parent style". +private static Integer levelOf(XWPFStyle style) { + String name = style.name ?: style.styleId + if (name =~ /(?i)subtitle/) { + return 2 + } + Integer outlineLevel = style.CTStyle.PPr?.isSetOutlineLvl() ? style.CTStyle.PPr.outlineLvl.val.intValue() : null + if (outlineLevel != null && outlineLevel >= BODY_TEXT_OUTLINE_LEVEL) { + return 0 + } + Matcher heading = HEADING_STYLE_NAME.matcher(name) + if (heading.find()) { + return heading.group(2) ? heading.group(2).toInteger() : (outlineLevel != null ? outlineLevel + 1 : 1) + } + if (BODY_STYLE_NAME.matcher(name).find()) { + return 0 + } + return outlineLevel != null ? outlineLevel + 1 : null +} diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxImages.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxImages.groovy new file mode 100644 index 00000000..6e8ea0cb --- /dev/null +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxImages.groovy @@ -0,0 +1,115 @@ +package com.quadient.migration.example.docx.parser + +import com.quadient.migration.api.Migration +import com.quadient.migration.api.dto.migrationmodel.builder.ImageBuilder +import com.quadient.migration.api.dto.migrationmodel.builder.ParagraphBuilder +import com.quadient.migration.shared.ImageOptions +import com.quadient.migration.shared.ImageType +import com.quadient.migration.shared.Size +import groovy.transform.Field +import org.apache.poi.common.usermodel.PictureType +import org.apache.poi.xwpf.usermodel.XWPFPicture +import org.apache.poi.xwpf.usermodel.XWPFPictureData +import org.apache.poi.xwpf.usermodel.XWPFRun + +@Field +static Map imageIdByChecksum = [:] +@Field +static int imageCounter = 0 + +static void resetImageState() { + imageIdByChecksum = [:] + imageCounter = 0 +} + +static void processRunImages(Migration migration, XWPFRun run, String fileName, List textBuilders, Set excludedEmbedIds = Collections.emptySet()) { + run.getEmbeddedPictures().each { XWPFPicture picture -> + XWPFPictureData data = picture.getPictureData() + if (data == null) { + return + } + String embedId = picture.getCTPicture()?.getBlipFill()?.getBlip()?.getEmbed() + if (embedId && excludedEmbedIds.contains(embedId)) { + return + } + String imageId = registerImageData(migration, data, fileName, resolveOptions(picture)) + if (imageId == null) { + return + } + ParagraphBuilder.TextBuilder textBuilder = new ParagraphBuilder.TextBuilder() + textBuilder.imageRef(imageId) + textBuilders.add(textBuilder) + } +} + +static String registerImageData(Migration migration, XWPFPictureData data, String fileName, ImageOptions options) { + Long checksum = data.getChecksum() + if (checksum != null && imageIdByChecksum.containsKey(checksum)) { + return imageIdByChecksum[checksum] + } + + ImageType imageType = toImageType(data.getPictureTypeEnum()) + if (imageType == ImageType.Unknown) { + // Unsupported/unrecognized format (e.g. EMF/WMF vector metafiles) - skip rather than upsert unusable data. + println " Warning: Skipping embedded image with unsupported type: ${data.getPictureTypeEnum()}" + return null + } + + String imageId = "${fileName}_img_${++imageCounter}" + String storagePath = "${imageId}${imageType.extension()}" + + // Storage has both String and byte[] overloads. Keep the declared type so a malformed/empty picture payload + // cannot make Groovy select neither overload at runtime. + byte[] imageBytes = data.getData() + migration.storage.write(storagePath, imageBytes) + + def imageBuilder = new ImageBuilder(imageId) + .sourcePath(storagePath) + .imageType(imageType) + .originLocations([fileName]) + + if (options) { + imageBuilder.options(options) + } + + migration.imageRepository.upsert(imageBuilder.build()) + + if (checksum != null) { + imageIdByChecksum[checksum] = imageId + } + return imageId +} + +private static ImageOptions resolveOptions(XWPFPicture picture) { + try { + double widthPt = picture.getWidth() + double heightPt = picture.getDepth() + if (widthPt > 0 && heightPt > 0) { + return new ImageOptions(Size.ofPoints(widthPt), Size.ofPoints(heightPt)) + } + } catch (Exception ignored) { + // Picture is missing shape xfrm/ext data - skip sizing rather than fail the whole run. + } + return null +} + +private static ImageType toImageType(PictureType pictureType) { + switch (pictureType) { + case PictureType.PNG: + return ImageType.Png + case PictureType.JPEG: + case PictureType.CMYKJPEG: + return ImageType.Jpeg + case PictureType.GIF: + return ImageType.Gif + case PictureType.BMP: + case PictureType.DIB: + return ImageType.Bmp + case PictureType.TIFF: + return ImageType.Tiff + case PictureType.SVG: + return ImageType.Svg + default: + return ImageType.Unknown + } +} diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxMergeFields.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxMergeFields.groovy new file mode 100644 index 00000000..c8d43343 --- /dev/null +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxMergeFields.groovy @@ -0,0 +1,585 @@ +package com.quadient.migration.example.docx.parser + +import com.quadient.migration.api.Migration +import com.quadient.migration.api.dto.migrationmodel.DisplayRuleRef +import com.quadient.migration.api.dto.migrationmodel.FirstMatch +import com.quadient.migration.api.dto.migrationmodel.StringValue +import com.quadient.migration.api.dto.migrationmodel.builder.DisplayRuleBuilder +import com.quadient.migration.api.dto.migrationmodel.builder.ParagraphBuilder +import com.quadient.migration.api.dto.migrationmodel.builder.VariableBuilder +import com.quadient.migration.shared.BinOp +import com.quadient.migration.shared.Binary +import com.quadient.migration.shared.DataType +import com.quadient.migration.shared.DisplayRuleDefinition +import com.quadient.migration.shared.Group +import com.quadient.migration.shared.GroupOp +import com.quadient.migration.shared.Literal +import com.quadient.migration.shared.LiteralDataType +import groovy.transform.Field +import org.apache.poi.xwpf.usermodel.XWPFRun +import org.apache.poi.xwpf.usermodel.XWPFTable +import org.apache.xmlbeans.XmlObject +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTFldChar +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTText + +import static com.quadient.migration.example.docx.util.DocxUtils.sha256Hex + +interface FieldPart {} + +class FieldTextPart implements FieldPart { + String text + String styleId +} + +// A body-level table that Word placed inside a field instruction (a whole table wrapped in an IF field). +class FieldTablePart implements FieldPart { + XWPFTable table +} + +class WordField implements FieldPart { + List instructionParts = [] + String styleId + boolean resultStarted = false + + void addPart(FieldPart part) { + // Only the instruction is interpreted; the cached field result (after "separate") is ignored. + if (!resultStarted) { + instructionParts.add(part) + } + } + + String instructionText() { + return instructionParts.findAll { it instanceof FieldTextPart } + .collect { (it as FieldTextPart).text } + .join("") + } + + boolean containsTable() { + return instructionParts.any { it instanceof FieldTablePart || (it instanceof WordField && it.containsTable()) } + } +} + +class FieldParseState { + List stack = [] + List pendingIfFields = [] + boolean pendingFirstMatchEligible = true + // Receives (XWPFTable, displayRuleId) for tables wrapped in a resolved IF field; null when tables cannot occur. + Closure conditionalTableHandler + + int getDepth() { + return stack.size() + } + + boolean isInsideFieldResult() { + return stack.any { it.resultStarted } + } +} + +class IfCondition { + String variableId + String operator + String rightOperand + boolean negated + + String canonical() { + return "${variableId} ${effectiveOperator()} ${rightOperand}" + } + + // A negated (else-branch) condition is expressed by inverting the operator so the rule stays a flat AND group. + String effectiveOperator() { + if (!negated) { + return operator + } + return switch (operator) { + case "=" -> "<>" + case "<>" -> "=" + case ">" -> "<=" + case "<=" -> ">" + case "<" -> ">=" + case ">=" -> "<" + default -> throw new IllegalArgumentException("Unsupported Word IF operator: ${operator}") + } + } + + Literal operandLiteral() { + if (rightOperand.startsWith('"')) { + return new Literal(rightOperand.substring(1, rightOperand.length() - 1), LiteralDataType.String) + } + return new Literal(rightOperand, LiteralDataType.Number) + } +} + +class ParsedIfField { + String variableId + String operator + String rightOperand + // Branch content is text, nested fields (IF / MERGEFIELD) or tables, in source order. + List trueContent + List falseContent + + String condition() { + return "${variableId} ${operator} ${rightOperand}" + } + + IfCondition toCondition(boolean negated) { + return new IfCondition(variableId: variableId, operator: operator, rightOperand: rightOperand, negated: negated) + } + + Literal operandLiteral() { + return toCondition(false).operandLiteral() + } +} + +@Field +static final char PLACEHOLDER = (char) 0xE000 + +static List fieldChildren(XWPFRun run) { + return run.CTR.selectPath("./*").findAll { it.domNode.localName in ["fldChar", "instrText"] } +} + +static boolean isFieldBegin(XmlObject child) { + return child.domNode.localName == "fldChar" && (child as CTFldChar).fldCharType.toString() == "begin" +} + +static boolean isInstructionText(XmlObject child) { + return child.domNode.localName == "instrText" +} + +static String instructionText(List fieldChildren) { + return fieldChildren.findAll { isInstructionText(it) }.collect { (it as CTText).stringValue }.join("") +} + +static void handleFieldChildren(Migration migration, List fieldChildren, String textStyleId, FieldParseState state, + List textBuilders, String fileName) { + fieldChildren.each { XmlObject child -> + if (child.domNode.localName == "fldChar") { + String type = (child as CTFldChar).fldCharType.toString() + handleFieldCharacter(migration, type, textStyleId, state, textBuilders, fileName) + } else if (state.depth > 0) { + state.stack.last().addPart(new FieldTextPart(text: (child as CTText).stringValue, styleId: textStyleId)) + } + } +} + +// Called for a body-level table encountered while a field is open; returns false when the table is a cached field result +// (content between "separate" and "end") that must not be emitted at all. +static boolean handleTableInsideField(FieldParseState state, XWPFTable table) { + if (state.insideFieldResult) { + return false + } + state.stack.last().addPart(new FieldTablePart(table: table)) + return true +} + +private static void handleFieldCharacter(Migration migration, String type, String textStyleId, FieldParseState state, + List textBuilders, String fileName) { + switch (type) { + case "begin": + WordField field = new WordField(styleId: textStyleId) + if (state.depth > 0) { + state.stack.last().addPart(field) + } + state.stack.add(field) + break + case "separate": + if (state.depth > 0) { + state.stack.last().resultStarted = true + } + break + case "end": + if (state.depth == 0) { + return + } + WordField completed = state.stack.removeLast() + if (state.depth == 0) { + resolveField(migration, state, textBuilders, fileName, completed) + } + break + } +} + +private static void resolveField(Migration migration, FieldParseState state, List textBuilders, String fileName, + WordField field) { + String instruction = field.instructionText() + if (isMergeField(instruction)) { + // A MERGEFIELD (or an unsupported field below) breaks a chain of IFs. + flushPendingIfFields(migration, state, textBuilders, fileName) + addMergeField(migration, textBuilders, fileName, instruction, field.styleId) + return + } + + ParsedIfField parsed = parseIfField(field) + if (parsed == null) { + flushPendingIfFields(migration, state, textBuilders, fileName) + if (field.containsTable()) { + println " Warning: Unsupported field wrapping a table, its tables are dropped: '${instruction.trim()}'" + } + return + } + + ensureVariable(migration, parsed.variableId, fileName) + if (!state.pendingIfFields.isEmpty() && state.pendingIfFields.first().variableId != parsed.variableId) { + flushPendingIfFields(migration, state, textBuilders, fileName) + } + state.pendingFirstMatchEligible = state.pendingIfFields.isEmpty() + ? isFirstMatchCandidate(parsed) + : state.pendingFirstMatchEligible && canJoinFirstMatch(state.pendingIfFields, parsed) + state.pendingIfFields.add(parsed) +} + +static void flushPendingIfFields(Migration migration, FieldParseState state, List textBuilders, String fileName) { + if (state.pendingIfFields.isEmpty()) { + return + } + + if (state.pendingIfFields.size() > 1 && state.pendingFirstMatchEligible) { + addFirstMatch(migration, textBuilders, fileName, state.pendingIfFields) + } else { + state.pendingIfFields.each { addIndependentConditional(migration, state, textBuilders, fileName, it) } + } + state.pendingIfFields.clear() + state.pendingFirstMatchEligible = true +} + +private static void addIndependentConditional(Migration migration, FieldParseState state, List textBuilders, + String fileName, ParsedIfField parsed) { + boolean blockLevel = containsTable(parsed.trueContent) || containsTable(parsed.falseContent) + emitBranch(migration, state, textBuilders, fileName, parsed.trueContent, [parsed.toCondition(false)], blockLevel) + emitBranch(migration, state, textBuilders, fileName, parsed.falseContent, [parsed.toCondition(true)], blockLevel) +} + +// Emits one IF branch; every nested IF extends the condition path, so each leaf gets a rule that ANDs all conditions +// leading to it. Whitespace-only text is dropped for fields wrapping tables, where it is just field-code formatting. +private static void emitBranch(Migration migration, FieldParseState state, List textBuilders, String fileName, + List parts, List path, boolean blockLevel) { + parts.each { FieldPart part -> + if (part instanceof FieldTextPart) { + if (part.text && !(blockLevel && part.text.isBlank())) { + textBuilders.add(new ParagraphBuilder.TextBuilder() + .string(part.text) + .styleRef(part.styleId) + .displayRuleRef(upsertDisplayRule(migration, path, fileName))) + } + } else if (part instanceof FieldTablePart) { + if (state.conditionalTableHandler != null) { + state.conditionalTableHandler.call(part.table, upsertDisplayRule(migration, path, fileName)) + } else { + println " Warning: Table inside IF field is not supported in this context and is dropped." + } + } else if (part instanceof WordField) { + emitNestedField(migration, state, textBuilders, fileName, part, path, blockLevel) + } + } +} + +private static void emitNestedField(Migration migration, FieldParseState state, List textBuilders, String fileName, + WordField field, List path, boolean blockLevel) { + String instruction = field.instructionText() + if (isMergeField(instruction)) { + String variableId = extractMergeFieldName(instruction) + if (variableId) { + ensureVariable(migration, variableId, fileName) + textBuilders.add(new ParagraphBuilder.TextBuilder() + .variableRef(variableId) + .styleRef(field.styleId) + .displayRuleRef(upsertDisplayRule(migration, path, fileName))) + } + return + } + ParsedIfField nested = parseIfField(field) + if (nested == null) { + println " Warning: Unsupported nested field inside IF branch is dropped: '${instruction.trim()}'" + return + } + ensureVariable(migration, nested.variableId, fileName) + emitBranch(migration, state, textBuilders, fileName, nested.trueContent, path + [nested.toCondition(false)], blockLevel) + emitBranch(migration, state, textBuilders, fileName, nested.falseContent, path + [nested.toCondition(true)], blockLevel) +} + +private static boolean containsTable(List parts) { + return parts.any { it instanceof FieldTablePart || (it instanceof WordField && it.containsTable()) } +} + +private static void addFirstMatch(Migration migration, List textBuilders, String fileName, + List fields) { + List cases = fields.collect { field -> + String ruleId = upsertDisplayRule(migration, [field.toCondition(false)], fileName) + new FirstMatch.Case( + new DisplayRuleRef(ruleId), + [new StringValue(field.trueContent*.text.join(""))], + field.condition() + ) + } + textBuilders.add(new ParagraphBuilder.TextBuilder() + .styleRef((fields.first().trueContent.first() as FieldTextPart).styleId) + .firstMatch(new FirstMatch(cases, []))) +} + +private static boolean canJoinFirstMatch(List fields, ParsedIfField candidate) { + return isFirstMatchCandidate(candidate) + && fields.every { isFirstMatchCandidate(it) } + && fields.first().variableId == candidate.variableId + && (fields.first().trueContent.first() as FieldTextPart).styleId == (candidate.trueContent.first() as FieldTextPart).styleId + && fields.every { it.operandLiteral() != candidate.operandLiteral() } +} + +private static boolean isFirstMatchCandidate(ParsedIfField field) { + return field.operator == "=" + && field.falseContent.isEmpty() + && field.trueContent.size() == 1 + && field.trueContent.first() instanceof FieldTextPart + && (field.trueContent.first() as FieldTextPart).text +} + +private static void addMergeField(Migration migration, List textBuilders, String fileName, + String fieldInstruction, String textStyleId) { + String variableId = extractMergeFieldName(fieldInstruction) + if (!variableId) { + return + } + ensureVariable(migration, variableId, fileName) + textBuilders.add(new ParagraphBuilder.TextBuilder() + .variableRef(variableId) + .styleRef(textStyleId)) +} + +private static void ensureVariable(Migration migration, String variableId, String fileName) { + if (migration.variableRepository.find(variableId) == null) { + migration.variableRepository.upsert(new VariableBuilder(variableId) + .name(variableId) + .originLocations([fileName]) + .dataType(DataType.String) + .build()) + } +} + +static String upsertDisplayRule(Migration migration, List conditions, String fileName) { + String canonical = conditions*.canonical().join(" AND ") + String ruleId = "docx_if_${sha256Hex(canonical).substring(0, 16)}" + if (migration.displayRuleRepository.find(ruleId) == null) { + migration.displayRuleRepository.upsert(new DisplayRuleBuilder(ruleId) + .name(canonical) + .originLocations([fileName]) + .addCustomField("originContent", canonical) + .definition(parseConditions(conditions)) + .build()) + } + return ruleId +} + +static DisplayRuleDefinition parseConditions(List conditions) { + List comparisons = conditions.collect { IfCondition condition -> + BinOp operator = switch (condition.effectiveOperator()) { + case "=" -> BinOp.Equals + case "<>" -> BinOp.NotEquals + case ">" -> BinOp.GreaterThan + case "<" -> BinOp.LessThan + case ">=" -> BinOp.GreaterOrEqualThan + case "<=" -> BinOp.LessOrEqualThen + default -> throw new IllegalArgumentException("Unsupported Word IF operator: ${condition.operator}") + } + new Binary(new Literal(condition.variableId, LiteralDataType.Variable), operator, condition.operandLiteral()) + } + return new DisplayRuleDefinition(new Group(comparisons, GroupOp.And, false)) +} + +class InstructionSpan { + int start + int end + FieldPart part +} + +class Token { + int contentStart + int contentEnd + int end +} + +static ParsedIfField parseIfField(WordField field) { + List spans = [] + StringBuilder flattened = new StringBuilder() + + // Nested fields and tables are replaced by private-use placeholder chars so the instruction can be scanned as one string. + field.instructionParts.each { FieldPart part -> + int start = flattened.length() + if (part instanceof FieldTextPart) { + flattened.append(part.text) + } else { + flattened.append(PLACEHOLDER) + } + spans.add(new InstructionSpan(start: start, end: flattened.length(), part: part)) + } + + String input = flattened.toString() + int index = skipWhitespace(input, 0) + if (!startsWithWordIgnoreCase(input, index, "IF")) { + return null + } + index = skipWhitespace(input, index + 2) + if (index >= input.length() || input.charAt(index) != PLACEHOLDER) { + return null + } + + FieldPart left = spans.find { it.start == index && !(it.part instanceof FieldTextPart) }?.part + if (!(left instanceof WordField)) { + return null + } + String variableId = extractMergeFieldName((left as WordField).instructionText()) + if (!variableId) { + return null + } + + index = skipWhitespace(input, index + 1) + String operator = [">=", "<=", "<>", "=", ">", "<"].find { input.startsWith(it, index) } + if (!operator) { + return null + } + index = skipWhitespace(input, index + operator.length()) + + Token right = readOperand(input, index) + if (right == null) { + return null + } + String rightOperand = input.substring(right.contentStart, right.end) + if (!isSupportedOperand(rightOperand)) { + return null + } + + Token trueBranch = readBranch(input, skipWhitespace(input, right.end)) + if (trueBranch == null) { + return null + } + // Word ignores anything after the false branch (switches, stray quotes), so no trailing validation is done. + Token falseBranch = readBranch(input, skipWhitespace(input, trueBranch.end)) + + return new ParsedIfField( + variableId: variableId, + operator: operator, + rightOperand: rightOperand, + trueContent: extractRange(spans, trueBranch), + falseContent: falseBranch == null ? [] : extractRange(spans, falseBranch) + ) +} + +private static boolean isSupportedOperand(String operand) { + return (operand.length() >= 2 && operand.startsWith('"') && operand.endsWith('"')) + || operand ==~ /[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?/ +} + +// The returned token spans the whole operand including quotes, so contentStart is the operand start. +private static Token readOperand(String input, int start) { + if (start >= input.length()) { + return null + } + if (input.charAt(start) == '"') { + Token quoted = readQuoted(input, start) + return quoted == null ? null : new Token(contentStart: start, contentEnd: quoted.end, end: quoted.end) + } + return readBareToken(input, start) +} + +// A branch is either a quoted string or a single bare token (typically just a nested field placeholder). +private static Token readBranch(String input, int start) { + if (start >= input.length()) { + return null + } + if (input.charAt(start) == '"') { + return readQuoted(input, start) + } + if (input.charAt(start) == '\\') { + return null + } + return readBareToken(input, start) +} + +private static Token readBareToken(String input, int start) { + int end = start + while (end < input.length() && !Character.isWhitespace(input.charAt(end))) { + end++ + } + return end == start ? null : new Token(contentStart: start, contentEnd: end, end: end) +} + +// Like Word, an unterminated quote runs to the end of the instruction. +private static Token readQuoted(String input, int start) { + if (start >= input.length() || input.charAt(start) != '"') { + return null + } + int index = start + 1 + while (index < input.length()) { + if (input.charAt(index) == '"' && (index == start + 1 || input.charAt(index - 1) != '\\')) { + return new Token(contentStart: start + 1, contentEnd: index, end: index + 1) + } + index++ + } + return new Token(contentStart: start + 1, contentEnd: input.length(), end: input.length()) +} + +private static List extractRange(List spans, Token range) { + List result = [] + spans.each { InstructionSpan span -> + int overlapStart = Math.max(range.contentStart, span.start) + int overlapEnd = Math.min(range.contentEnd, span.end) + if (overlapStart >= overlapEnd) { + return + } + if (!(span.part instanceof FieldTextPart)) { + result.add(span.part) + return + } + FieldTextPart textPart = span.part as FieldTextPart + String text = textPart.text.substring(overlapStart - span.start, overlapEnd - span.start) + FieldTextPart previous = result && result.last() instanceof FieldTextPart ? result.last() as FieldTextPart : null + // Whitespace carries no visible formatting, so it is glued to the neighbouring text instead of becoming a + // separate (ruled) text of its own. + if (previous != null && (previous.styleId == textPart.styleId || text.isBlank())) { + previous.text += text + } else if (previous != null && previous.text.isBlank()) { + previous.text += text + previous.styleId = textPart.styleId + } else { + result.add(new FieldTextPart(text: text, styleId: textPart.styleId)) + } + } + return result +} + +private static int skipWhitespace(String input, int index) { + while (index < input.length() && Character.isWhitespace(input.charAt(index))) { + index++ + } + return index +} + +// A nested field directly after the keyword (e.g. "if«MERGEFIELD X»") also terminates the word, as in Word. +private static boolean startsWithWordIgnoreCase(String input, int index, String word) { + if (index + word.length() > input.length() + || !input.regionMatches(true, index, word, 0, word.length())) { + return false + } + int end = index + word.length() + return end == input.length() || Character.isWhitespace(input.charAt(end)) || input.charAt(end) == PLACEHOLDER +} + +static boolean isMergeField(String fieldInstruction) { + return fieldInstruction?.trim()?.toUpperCase(Locale.ROOT)?.startsWith("MERGEFIELD") +} + +static String extractMergeFieldName(String fieldInstruction) { + String instr = fieldInstruction?.trim() + if (!instr) { + return null + } + String remainder = instr.replaceFirst(/(?i)^MERGEFIELD\b\s*/, "").trim() + if (!remainder) { + return null + } + def quoted = (remainder =~ /^"([^"]+)"/) + if (quoted.find()) { + return quoted.group(1).trim() + } + def switchMatcher = (remainder =~ /\\\S/) + String name = switchMatcher.find() ? remainder.substring(0, switchMatcher.start()) : remainder + name = name.trim() + return name ?: null +} diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxPageLayout.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxPageLayout.groovy new file mode 100644 index 00000000..2854d2f7 --- /dev/null +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxPageLayout.groovy @@ -0,0 +1,52 @@ +package com.quadient.migration.example.docx.parser + +import com.quadient.migration.shared.Position +import com.quadient.migration.shared.Size +import groovy.transform.Field +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageMar +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSectPr + +import static com.quadient.migration.example.docx.util.DocxUtils.twipsToPoints + +@Field +static final List DEFAULT_PAGE_SIZE = [Size.ofMillimeters(210), Size.ofMillimeters(297)] +@Field +static final Map DEFAULT_MARGINS = [ + left: Size.ofMillimeters(20), right: Size.ofMillimeters(20), + top: Size.ofMillimeters(20), bottom: Size.ofMillimeters(37), +] +// Preserve the existing 170 x 240 mm fallback content area on an A4 page. +@Field +static final Position DEFAULT_CONTENT_POSITION = new Position( + DEFAULT_MARGINS.left, DEFAULT_MARGINS.top, + Size.ofPoints(DEFAULT_PAGE_SIZE[0].toPoints() - DEFAULT_MARGINS.left.toPoints() - DEFAULT_MARGINS.right.toPoints()), + Size.ofPoints(DEFAULT_PAGE_SIZE[1].toPoints() - DEFAULT_MARGINS.top.toPoints() - DEFAULT_MARGINS.bottom.toPoints())) + +static Position resolvePageContentPosition(CTSectPr sectPr) { + CTPageMar pageMar = sectPr?.pgMar + def (pageWidth, pageHeight) = resolvePageSize(sectPr)*.toPoints() + Size top = resolveDimension(pageMar?.top, DEFAULT_MARGINS.top) + Size left = resolveDimension(pageMar?.left, DEFAULT_MARGINS.left) + double marginTop = top.toPoints() + double marginRight = resolveDimension(pageMar?.right, DEFAULT_MARGINS.right).toPoints() + double marginBottom = resolveDimension(pageMar?.bottom, DEFAULT_MARGINS.bottom).toPoints() + double marginLeft = left.toPoints() + + double contentWidth = pageWidth - marginLeft - marginRight + double contentHeight = pageHeight - marginTop - marginBottom + if (contentWidth <= 0 || contentHeight <= 0) { + return DEFAULT_CONTENT_POSITION + } + return new Position(left, top, Size.ofPoints(contentWidth), Size.ofPoints(contentHeight)) +} + +static List resolvePageSize(CTSectPr sectPr) { + return [resolveDimension(sectPr?.pgSz?.w, DEFAULT_PAGE_SIZE[0]), + resolveDimension(sectPr?.pgSz?.h, DEFAULT_PAGE_SIZE[1])] +} + +private static Size resolveDimension(Object twips, Size fallback) { + Double points = twipsToPoints(twips) + // Zero is a valid explicit margin, so do not use Groovy's truth-based fallback. + return points != null ? Size.ofPoints(points) : fallback +} diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxPageSections.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxPageSections.groovy new file mode 100644 index 00000000..cb0a989b --- /dev/null +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxPageSections.groovy @@ -0,0 +1,124 @@ +package com.quadient.migration.example.docx.parser + +import com.quadient.migration.api.dto.migrationmodel.DocumentContent +import com.quadient.migration.shared.Position +import com.quadient.migration.shared.Size +import org.apache.poi.xwpf.usermodel.IBodyElement +import org.apache.poi.xwpf.usermodel.XWPFDocument +import org.apache.poi.xwpf.usermodel.XWPFParagraph +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSectPr +import org.openxmlformats.schemas.wordprocessingml.x2006.main.STSectionMark + +class DocxSection { + List elements = [] + CTSectPr sectPr +} + +class DocxPage { + int index + List sections = [] + Position contentPosition + Size width + Size height + List content = [] + + List bodyElements() { + return sections.collectMany { it.elements } + } + + String id(String fileName) { + return "${fileName}_page${index + 1}" + } + + List paragraphs() { + return bodyElements().findAll { it instanceof XWPFParagraph } as List + } +} + +class DocxPageSections { + static List splitIntoSections(XWPFDocument doc) { + List sections = [] + DocxSection current = new DocxSection() + doc.bodyElements.each { IBodyElement elem -> + current.elements.add(elem) + CTSectPr sectPr = elem instanceof XWPFParagraph ? elem.CTP.PPr?.sectPr : null + if (sectPr != null) { + current.sectPr = sectPr + sections.add(current) + current = new DocxSection() + } + } + if (!current.elements.isEmpty()) { + current.sectPr = doc.document?.body?.sectPr + sections.add(current) + } + return sections + } + + static List groupIntoPages(List sections) { + List pages = [] + sections.eachWithIndex { DocxSection section, int i -> + // A section can contain Word's cached pagination marker. It is not an authored page break, so only + // honor it where it begins a paragraph; using a marker after text would move already-laid-out content. + splitAtRenderedPageBreaks(section).eachWithIndex { DocxSection fragment, int fragmentIndex -> + if ((i == 0 && fragmentIndex == 0) || fragmentIndex > 0 || startsNewPage(section.sectPr)) { + pages.add(newPage(pages.size(), fragment)) + } else { + pages.last().sections.add(fragment) + } + } + } + return pages + } + + private static List splitAtRenderedPageBreaks(DocxSection section) { + if (!section.elements.any { it instanceof XWPFParagraph && beginsAfterRenderedPageBreak(it) }) { + // Preserve the original section object when no cached pagination is involved. + return [section] + } + List fragments = [] + DocxSection current = new DocxSection(sectPr: section.sectPr) + section.elements.each { IBodyElement element -> + if (element instanceof XWPFParagraph && beginsAfterRenderedPageBreak(element) && !current.elements.isEmpty()) { + fragments.add(current) + current = new DocxSection(sectPr: section.sectPr) + } + current.elements.add(element) + } + if (!current.elements.isEmpty()) { + fragments.add(current) + } + return fragments + } + + private static boolean beginsAfterRenderedPageBreak(XWPFParagraph paragraph) { + String xml = paragraph.CTP.xmlText() + def marker = xml =~ /<(?:[A-Za-z_][\w.-]*:)?lastRenderedPageBreak(?=[\s\/>])/ + if (!marker.find()) { + return false + } + // These are the WordprocessingML elements that produce visible paragraph content. The marker is safe to + // treat as a page boundary only when none of them precedes it. + def visibleContent = xml =~ /<(?:[A-Za-z_][\w.-]*:)?(?:t|instrText|drawing|tab|br|object)(?=[\s\/>])/ + return !visibleContent.find() || marker.start() < visibleContent.start() + } + + private static DocxPage newPage(int index, DocxSection firstSection) { + List size = DocxPageLayout.resolvePageSize(firstSection.sectPr) + return new DocxPage( + index: index, + sections: [firstSection], + contentPosition: DocxPageLayout.resolvePageContentPosition(firstSection.sectPr), + width: size[0], + height: size[1], + ) + } + + private static boolean startsNewPage(CTSectPr sectPr) { + if (sectPr == null || !sectPr.isSetType()) { + return true + } + def mark = sectPr.type.val + return mark != STSectionMark.CONTINUOUS && mark != STSectionMark.NEXT_COLUMN + } +} diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxParagraphParser.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxParagraphParser.groovy new file mode 100644 index 00000000..0e0f29e9 --- /dev/null +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxParagraphParser.groovy @@ -0,0 +1,133 @@ +package com.quadient.migration.example.docx.parser + +import com.quadient.migration.api.Migration +import com.quadient.migration.api.dto.migrationmodel.Paragraph +import com.quadient.migration.api.dto.migrationmodel.builder.ParagraphBuilder +import org.apache.poi.xwpf.usermodel.XWPFParagraph +import org.apache.poi.xwpf.usermodel.XWPFRun +import org.apache.xmlbeans.XmlObject + +import static com.quadient.migration.example.docx.style.DocxParagraphStyles.captureParagraphStyle +import static com.quadient.migration.example.docx.style.DocxTextStyles.captureTextStyle + +class ParagraphContentCollector { + private final Migration migration + private final String fileName + private final Set excludedImageEmbedIds + + private final List textBuilders = [] + // Same-styled text runs and adjacent IF fields are buffered until a run that does not belong to the group arrives; + // a run is either text or field, so at most one of the two buffers is non-empty at any time. + private final StringBuilder text = new StringBuilder() + private String textStyleId + // The field state may be shared by consecutive paragraphs (cell, body flow) so that fields spanning paragraphs resolve. + private final FieldParseState fieldState + private boolean containsFieldCode = false + + ParagraphContentCollector(Migration migration, String fileName, Set excludedImageEmbedIds = Collections.emptySet(), + FieldParseState fieldState = null) { + this.migration = migration + this.fileName = fileName + this.excludedImageEmbedIds = excludedImageEmbedIds + this.fieldState = fieldState ?: new FieldParseState() + } + + void addRun(XWPFRun run, String styleId) { + if (!run.embeddedPictures.isEmpty()) { + flushText() + flushFields() + DocxImages.processRunImages(migration, run, fileName, textBuilders, excludedImageEmbedIds) + } + + List fieldChildren = DocxMergeFields.fieldChildren(run) + if (fieldState.depth == 0 && fieldChildren.every { DocxMergeFields.isInstructionText(it) }) { + flushFields() + // Word marks every run inside a field instruction as instrText, including the plain text of a table + // wrapped in a body-level IF field. Outside any open field such text is ordinary content. + appendText(fieldChildren.isEmpty() ? run.text() : DocxMergeFields.instructionText(fieldChildren), styleId) + return + } + containsFieldCode = true + if (fieldState.depth == 0 && fieldChildren.any { DocxMergeFields.isFieldBegin(it) }) { + flushText() + } + DocxMergeFields.handleFieldChildren(migration, fieldChildren, styleId, fieldState, textBuilders, fileName) + } + + List finish() { + flushText() + flushFields() + return textBuilders + } + + // True for a paragraph that carried only field code (instructions, cached results) and produced no content. + boolean isFieldCodeOnly() { + return containsFieldCode && textBuilders.isEmpty() + } + + private void appendText(String runText, String styleId) { + if (!runText) { + return + } + if (text.length() > 0 && styleId != textStyleId) { + flushText() + } + textStyleId = styleId + text.append(runText) + } + + private void flushText() { + if (text.length() == 0) { + return + } + DocxVariablePatterns.addText(migration, textBuilders, text.toString(), textStyleId, fileName) + text.setLength(0) + } + + private void flushFields() { + DocxMergeFields.flushPendingIfFields(migration, fieldState, textBuilders, fileName) + } +} + +static Paragraph parseParagraph(Migration migration, XWPFParagraph paragraph, String fileName, String context = null, + Set excludedImageEmbedIds = Collections.emptySet()) { + FieldParseState fieldState = new FieldParseState() + Paragraph parsed = parseFlowParagraph(migration, paragraph, fileName, fieldState, context, excludedImageEmbedIds) + warnUnterminatedField(fieldState, "paragraph: '${paragraph.text}'") + return parsed ?: buildParagraph(migration, paragraph, fileName, context, []) +} + +// Parses a paragraph that belongs to a flow of paragraphs sharing one field state (table cell, page body). Returns null +// for paragraphs that carried nothing but field code, e.g. the instruction part of an IF continued in the next paragraph. +static Paragraph parseFlowParagraph(Migration migration, XWPFParagraph paragraph, String fileName, FieldParseState fieldState, + String context = null, Set excludedImageEmbedIds = Collections.emptySet()) { + String paragraphStyleId = paragraph.styleID ?: "unknown" + ParagraphContentCollector collector = new ParagraphContentCollector(migration, fileName, excludedImageEmbedIds, fieldState) + + paragraph.runs.each { XWPFRun run -> + collector.addRun(run, captureTextStyle(migration, run, fileName, paragraphStyleId, context)) + } + + List content = collector.finish() + if (collector.fieldCodeOnly) { + return null + } + return buildParagraph(migration, paragraph, fileName, context, content) +} + +static void warnUnterminatedField(FieldParseState fieldState, String location) { + if (fieldState.depth > 0) { + println " Warning: Unterminated complex field (missing fldChar end) in ${location}" + fieldState.stack.clear() + } +} + +private static Paragraph buildParagraph(Migration migration, XWPFParagraph paragraph, String fileName, String context, + List content) { + ParagraphBuilder paragraphBuilder = new ParagraphBuilder().content(content) + String styleId = captureParagraphStyle(migration, paragraph, fileName, paragraph.styleID ?: "unknown", context) + if (styleId) { + paragraphBuilder.styleRef(styleId) + } + return paragraphBuilder.build() +} diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxStatistics.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxStatistics.groovy new file mode 100644 index 00000000..7a8e837f --- /dev/null +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxStatistics.groovy @@ -0,0 +1,40 @@ +package com.quadient.migration.example.docx.parser + +import com.quadient.migration.api.dto.migrationmodel.builder.DocumentObjectBuilder +import org.apache.poi.xwpf.usermodel.XWPFDocument + +static void captureDocumentStatistics(XWPFDocument doc, DocumentObjectBuilder builder) { + int shapeCount = doc.paragraphs.sum(0) { paragraph -> paragraph.runs.count { it.CTR.xmlText().contains(" table.rows.sum(0) { it.tableCells.size() } } as int + long textLength = (doc.paragraphs.sum(0) { it.text.length() } as long) + (doc.tables.sum(0) { it.text.length() } as long) + + addCountField(builder, "shapes", shapeCount) + addCountField(builder, "headers", doc.headerList.size()) + addCountField(builder, "footers", doc.footerList.size()) + addCountField(builder, "images", doc.allPictures.size()) + addCountField(builder, "paragraphs", doc.paragraphs.count { it.text.length() > 0 } as int) + addCountField(builder, "tables", doc.tables.size()) + if (doc.tables) { + builder.addCustomField("cells", cellCount.toString()) + } + addCountField(builder, "textLength", textLength) +} + +private static void addCountField(DocumentObjectBuilder builder, String name, Number count) { + if (count > 0) { + builder.addCustomField(name, count.toString()) + } +} + +static void captureDocxFileProperties(XWPFDocument doc, DocumentObjectBuilder builder) { + def coreProps = doc.properties.coreProperties + builder.addCustomField("Title", coreProps.title.toString(), coreProps.title != null) + + def extProps = doc.properties.extendedProperties + if (extProps.application) { + builder.addCustomField("Application", extProps.application) + } + if (extProps.appVersion) { + builder.addCustomField("AppVersion", extProps.appVersion) + } +} diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxTableParser.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxTableParser.groovy new file mode 100644 index 00000000..f5509485 --- /dev/null +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxTableParser.groovy @@ -0,0 +1,103 @@ +package com.quadient.migration.example.docx.parser + +import com.quadient.migration.api.Migration +import com.quadient.migration.api.dto.migrationmodel.DocumentContent +import com.quadient.migration.api.dto.migrationmodel.Table +import com.quadient.migration.api.dto.migrationmodel.builder.TableBuilder +import com.quadient.migration.shared.TableAlignment +import com.quadient.migration.shared.TablePdfTaggingRule +import groovy.transform.Field +import org.apache.poi.xwpf.usermodel.XWPFTable +import org.apache.poi.xwpf.usermodel.XWPFTableCell +import org.apache.poi.xwpf.usermodel.XWPFTableRow + +import static com.quadient.migration.example.docx.parser.DocxParagraphParser.parseFlowParagraph +import static com.quadient.migration.example.docx.parser.DocxParagraphParser.parseParagraph +import static com.quadient.migration.example.docx.parser.DocxParagraphParser.warnUnterminatedField +import static com.quadient.migration.example.docx.style.DocxTableStyling.applyCellStyling +import static com.quadient.migration.example.docx.style.DocxTableStyling.applyColumnWidths +import static com.quadient.migration.example.docx.style.DocxTableStyling.applyRowHeight +import static com.quadient.migration.example.docx.util.DocxUtils.isHorizontallyMergedCell + +@Field +static final long GRID_TOLERANCE_TWIPS = 30 + +static Table parseTable(Migration migration, XWPFTable table, String fileName) { + return buildTable(migration, table, table.rows, fileName, resolveExpectedColumnCount(table), true) +} + +static int resolveExpectedColumnCount(XWPFTable table) { + def gridCols = table.CTTbl.tblGrid?.gridColList + int gridColumnCount = gridCols ? gridCols.size() : table.rows[0].tableCells.size() + int maxCellsInRow = table.rows.collect { it.tableCells.size() }.max() + return Math.max(gridColumnCount, maxCellsInRow) +} + +static Table buildTable(Migration migration, XWPFTable sourceTable, List rows, String fileName, + int expectedColumnCount, boolean useHeader) { + TableBuilder tableBuilder = createTableBuilder(migration, sourceTable, useHeader) + addRows(migration, tableBuilder, sourceTable, rows, fileName, expectedColumnCount, useHeader) + return tableBuilder.build() +} + +static TableBuilder createTableBuilder(Migration migration, XWPFTable sourceTable, boolean useHeader) { + TableBuilder tableBuilder = new TableBuilder() + .pdfTaggingRule(useHeader ? TablePdfTaggingRule.Table : TablePdfTaggingRule.None) + .alignment(TableAlignment.Center) + applyColumnWidths(tableBuilder, sourceTable) + if (migration.projectConfig.context.defaultTableStyleName) { + tableBuilder.tableStyleName(migration.projectConfig.context.defaultTableStyleName.toString()) + } + return tableBuilder +} + +// Two Word tables can be joined into one model table when their column grids match. Word lets grid widths drift by a +// few twips between otherwise identical tables, so a small tolerance is applied. +static boolean haveSameGrid(XWPFTable first, XWPFTable second) { + List firstWidths = first.CTTbl.tblGrid?.gridColList?.collect { it.w as Long } ?: [] + List secondWidths = second.CTTbl.tblGrid?.gridColList?.collect { it.w as Long } ?: [] + return firstWidths.size() == secondWidths.size() + && [firstWidths, secondWidths].transpose().every { Math.abs((it[0] as long) - (it[1] as long)) <= GRID_TOLERANCE_TWIPS } +} + +static void addRows(Migration migration, TableBuilder tableBuilder, XWPFTable sourceTable, List rows, String fileName, + int expectedColumnCount, boolean useHeader, String displayRuleId = null) { + rows.eachWithIndex { XWPFTableRow row, int ri -> + if (row.tableCells.size() != expectedColumnCount) { + println " Warning: Row ${ri + 1} has ${row.tableCells.size()} cells, expected ${expectedColumnCount}." + } + boolean isHeader = useHeader && ri == 0 && rows.size() > 1 + TableBuilder.Row rowBuilder = isHeader ? tableBuilder.addFirstHeaderRow() : tableBuilder.addRow() + if (displayRuleId) { + rowBuilder.displayRuleRef(displayRuleId) + } + String context = isHeader ? "tableHeader" : "tableRow" + + row.tableCells.eachWithIndex { XWPFTableCell cell, int ci -> + TableBuilder.Cell cellBuilder = rowBuilder.addCell() + applyCellStyling(cellBuilder, cell, sourceTable, ri, ci, rows.size(), row.tableCells.size()) + applyRowHeight(cellBuilder, row) + if (isHorizontallyMergedCell(cell)) { + cellBuilder.mergeLeft = true + } else { + cellBuilder.content(parseCellParagraphs(migration, cell, fileName, context)) + } + } + int missingCells = expectedColumnCount - rowBuilder.cells.size() + if (missingCells > 0) { + println " Adding ${missingCells} empty cells to row ${ri + 1} to match expected column count." + missingCells.times { rowBuilder.addCell().mergeLeft = true } + } + } +} + +// Paragraphs of one cell share the field state so an IF whose instruction and result sit in different paragraphs resolves. +private static List parseCellParagraphs(Migration migration, XWPFTableCell cell, String fileName, String context) { + FieldParseState fieldState = new FieldParseState() + List paragraphs = cell.paragraphs.findResults { parseFlowParagraph(migration, it, fileName, fieldState, context) } + warnUnterminatedField(fieldState, "table cell: '${cell.text}'") + if (paragraphs.isEmpty() && cell.paragraphs) { + paragraphs.add(parseParagraph(migration, cell.paragraphs.first(), fileName, context)) + } + return paragraphs +} diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxTemplateParser.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxTemplateParser.groovy new file mode 100644 index 00000000..2e9b1cf0 --- /dev/null +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxTemplateParser.groovy @@ -0,0 +1,178 @@ +package com.quadient.migration.example.docx.parser + +import com.quadient.migration.api.Migration +import com.quadient.migration.api.dto.migrationmodel.Area +import com.quadient.migration.api.dto.migrationmodel.DocumentObject +import com.quadient.migration.api.dto.migrationmodel.DocumentObjectRef +import com.quadient.migration.api.dto.migrationmodel.PageOptions +import com.quadient.migration.api.dto.migrationmodel.Paragraph +import com.quadient.migration.api.dto.migrationmodel.builder.DocumentObjectBuilder +import com.quadient.migration.api.dto.migrationmodel.builder.PdfMetadataBuilder +import com.quadient.migration.api.dto.migrationmodel.Table +import com.quadient.migration.api.dto.migrationmodel.builder.TableBuilder +import com.quadient.migration.api.dto.migrationmodel.builder.documentcontent.AreaBuilder +import com.quadient.migration.shared.DocumentObjectType +import groovy.io.FileType +import org.apache.poi.xwpf.usermodel.XWPFDocument +import org.apache.poi.xwpf.usermodel.XWPFParagraph +import org.apache.poi.xwpf.usermodel.XWPFTable + +import java.util.regex.Pattern + +import static com.quadient.migration.example.docx.parser.DocxBlocks.blockName +import static com.quadient.migration.example.docx.parser.DocxBlocks.upsertBlock +import static com.quadient.migration.example.docx.parser.DocxHeadings.headingLevel +import static com.quadient.migration.example.docx.parser.DocxMergeFields.handleTableInsideField +import static com.quadient.migration.example.docx.parser.DocxParagraphParser.parseFlowParagraph +import static com.quadient.migration.example.docx.parser.DocxParagraphParser.warnUnterminatedField +import static com.quadient.migration.example.docx.parser.DocxStatistics.captureDocumentStatistics +import static com.quadient.migration.example.docx.parser.DocxStatistics.captureDocxFileProperties +import static com.quadient.migration.example.docx.parser.DocxTableParser.addRows +import static com.quadient.migration.example.docx.parser.DocxTableParser.createTableBuilder +import static com.quadient.migration.example.docx.parser.DocxTableParser.parseTable +import static com.quadient.migration.example.docx.parser.DocxTableParser.resolveExpectedColumnCount + +static void parseDocxFiles(Migration migration) { + List inputFiles = [] + new File(migration.projectConfig.inputDataPath).eachFileRecurse(FileType.FILES) { File file -> + if (file.name.endsWith('.docx') && !file.name.startsWith('~$')) { + inputFiles.add(file) + } + } + inputFiles.each { parseDocxFile(migration, it) } +} + +static void parseDocxFile(Migration migration, File file) { + String fileName = file.name.substring(0, file.name.lastIndexOf('.')) + String documentType = file.parentFile.name + String relativePath = new File(migration.projectConfig.inputDataPath).toPath().relativize(file.toPath()).toString() + + println("=== Processing: " + relativePath + " ===") + DocumentObjectBuilder builder = new DocumentObjectBuilder(fileName, DocumentObjectType.Template) + .name(fileName) + .originLocations([relativePath]) + .targetFolder(resolveTargetFolder(migration, relativePath)) + .addCustomField("size", file.length().toString()) + .addCustomField("documentType", documentType) + + parsePages(migration, file, builder, fileName).each { builder.documentObjectRef(it) } + + DocumentObject documentObject = builder.build() + migration.documentObjectRepository.upsert(documentObject) +} + +private static String resolveTargetFolder(Migration migration, String relativePath) { + String[] folders = relativePath.split(Pattern.quote(File.separator)) + String subfolder = folders.length > 1 ? folders[0..-2].join(File.separator) : '' + String defaultTargetFolder = migration.projectConfig.defaultTargetFolder.toString() + return [defaultTargetFolder, subfolder].findAll().join('/') +} + +static List parsePages(Migration migration, File docxFile, DocumentObjectBuilder templateBuilder, String fileName) { + XWPFDocument doc = new XWPFDocument(new FileInputStream(docxFile)) + try { + captureDocxFileProperties(doc, templateBuilder) + setPdfMetadata(templateBuilder, doc) + DocxImages.resetImageState() + + List pages = DocxPageSections.groupIntoPages(DocxPageSections.splitIntoSections(doc)) + DocxAnchoredAreas anchoredAreas = DocxAnchoredAreas.extract(migration, doc, fileName, pages) + + int otherElements = 0 + pages.each { DocxPage page -> + String pageId = page.id(fileName) + DocxBodyContent body = new DocxBodyContent(migration, fileName, pageId) + FieldParseState fieldState = new FieldParseState(conditionalTableHandler: body.&addConditionalTable) + int floatingTables = 0 + page.bodyElements().each { elem -> + if (elem instanceof XWPFParagraph) { + Paragraph paragraph = parseFlowParagraph(migration, elem, fileName, fieldState, null, anchoredAreas.consumedEmbedIds) + if (paragraph != null) { + body.add(paragraph, headingLevel(elem)) + } + } else if (elem instanceof XWPFTable) { + if (fieldState.depth > 0) { + // The table sits inside an open IF field; it is emitted (with a display rule) once the field resolves. + handleTableInsideField(fieldState, elem) + } else if (DocxFloatingTables.isFloating(elem)) { + Area area = buildFloatingTableArea(migration, elem, page, fileName, "${pageId}_floating_table${++floatingTables}") + if (area != null) { + anchoredAreas.floatingAreasByPage.computeIfAbsent(page.index) { [] }.add(area) + } + } else { + addBodyTable(migration, body, elem, fileName) + } + } else { + otherElements++ + } + } + warnUnterminatedField(fieldState, "page ${page.index + 1} body") + page.content = body.sectionBlocks() + } + if (otherElements > 0) { + templateBuilder.addCustomField("otherElements", otherElements.toString()) + } + + captureDocumentStatistics(doc, templateBuilder) + + return pages.collect { buildPage(migration, doc, fileName, it, anchoredAreas) } + } finally { + doc.close() + } +} + +// A floating table is positioned absolutely, so it becomes an area of its own whose content is kept in a separate block. +private static Area buildFloatingTableArea(Migration migration, XWPFTable table, DocxPage page, String fileName, String blockId) { + Table parsedTable = parseTable(migration, table, fileName) + String firstCellText = table.rows[0]?.tableCells?.collect { it.text.trim() }?.find { it } + DocumentObjectRef blockRef = upsertBlock(migration, blockId, blockName(firstCellText, "table", blockId), [parsedTable], fileName) + return DocxFloatingTables.buildFloatingTableArea(table, [blockRef], page.contentPosition) +} + +private static void addBodyTable(Migration migration, DocxBodyContent body, XWPFTable table, String fileName) { + int columnCount = resolveExpectedColumnCount(table) + TableBuilder tableBuilder = createTableBuilder(migration, table, true) + addRows(migration, tableBuilder, table, table.rows, fileName, columnCount, true) + body.addTable(table, tableBuilder, columnCount) +} + +private static DocumentObject buildPage(Migration migration, XWPFDocument doc, String fileName, DocxPage page, DocxAnchoredAreas anchoredAreas) { + Area mainFlowArea = new AreaBuilder() + .interactiveFlowName("Letter Content") + .content(page.content) + .position(page.contentPosition) + .flowToNextPage(true) + .build() + + int pageNumber = page.index + 1 + DocumentObjectBuilder pageBuilder = new DocumentObjectBuilder(page.id(fileName), DocumentObjectType.Page) + .internal(true) + .name("${fileName} Page ${pageNumber}") + .originLocations([fileName]) + .options(new PageOptions(page.width, page.height)) + anchoredAreas.backgroundAreasByPage[page.index]?.each { pageBuilder.area(it) } + DocxHeaderFooters.headerAreas(migration, doc, page, fileName).each { pageBuilder.area(it) } + pageBuilder.area(mainFlowArea) + DocxHeaderFooters.footerAreas(migration, doc, page, fileName).each { pageBuilder.area(it) } + anchoredAreas.floatingAreasByPage[page.index]?.each { pageBuilder.area(it) } + + DocumentObject pageObject = pageBuilder.build() + migration.documentObjectRepository.upsert(pageObject) + return pageObject +} + +// The PDF metadata of the output is taken from the document properties of the DOCX file. +static void setPdfMetadata(DocumentObjectBuilder builder, XWPFDocument doc) { + def coreProps = doc.properties.coreProperties + String title = coreProps.title?.trim() + String author = coreProps.creator?.trim() + String subject = coreProps.subject?.trim() + if (!title && !author && !subject) { + return + } + PdfMetadataBuilder pdfMetadata = new PdfMetadataBuilder() + if (title) pdfMetadata.title(title) + if (author) pdfMetadata.author(author) + if (subject) pdfMetadata.subject(subject) + builder.setPdfMetadata(pdfMetadata.build()) +} diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxVariablePatterns.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxVariablePatterns.groovy new file mode 100644 index 00000000..b64f5217 --- /dev/null +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxVariablePatterns.groovy @@ -0,0 +1,90 @@ +package com.quadient.migration.example.docx.parser + +import com.quadient.migration.api.Migration +import com.quadient.migration.api.dto.migrationmodel.builder.ParagraphBuilder +import com.quadient.migration.api.dto.migrationmodel.builder.VariableBuilder +import com.quadient.migration.shared.DataType + +import java.util.regex.Pattern + +/** + * Converts project-configured, text-based variable markers (for example ${Name} or <>) to variable references. + * Each regular expression must contain one capturing group: group 1 becomes the variable name. + */ +class DocxVariablePatterns { + static final String CONTEXT_KEY = "docxVariablePatterns" + + static void addText(Migration migration, List textBuilders, String text, String styleId, + String fileName) { + List patterns = configuredPatterns(migration) + if (!text || patterns.isEmpty()) { + addLiteral(textBuilders, text, styleId) + return + } + + List matches = patterns.collectMany { pattern -> + def matcher = pattern.matcher(text) + def result = [] + while (matcher.find()) { + String variableId = matcher.groupCount() >= 1 ? matcher.group(1)?.trim() : null + if (variableId) { + result.add(new VariableMatch(start: matcher.start(), end: matcher.end(), variableId: variableId)) + } + } + result + }.sort { left, right -> left.start <=> right.start ?: right.end <=> left.end } + + int offset = 0 + matches.each { match -> + // First configured pattern wins for overlapping matches, avoiding duplicate variable references. + if (match.start < offset) { + return + } + addLiteral(textBuilders, text.substring(offset, match.start), styleId) + ensureVariable(migration, match.variableId, fileName) + textBuilders.add(new ParagraphBuilder.TextBuilder().variableRef(match.variableId).styleRef(styleId)) + offset = match.end + } + addLiteral(textBuilders, text.substring(offset), styleId) + } + + private static List configuredPatterns(Migration migration) { + def configured = migration.projectConfig.context?.get(CONTEXT_KEY) + Collection values = configured instanceof Collection ? configured : configured == null ? [] : [configured] + return values.collect { value -> + try { + Pattern pattern = value instanceof Pattern ? value : Pattern.compile(value.toString()) + if (pattern.matcher("").groupCount() < 1) { + println " Warning: DOCX variable pattern '${value}' has no capture group; it is ignored." + return null + } + pattern + } catch (Exception e) { + println " Warning: Invalid DOCX variable pattern '${value}': ${e.message}" + null + } + }.findAll() + } + + private static void addLiteral(List textBuilders, String text, String styleId) { + if (text) { + textBuilders.add(new ParagraphBuilder.TextBuilder().string(text).styleRef(styleId)) + } + } + + private static void ensureVariable(Migration migration, String variableId, String fileName) { + if (migration.variableRepository.find(variableId) == null) { + migration.variableRepository.upsert(new VariableBuilder(variableId) + .name(variableId) + .originLocations([fileName]) + .dataType(DataType.String) + .build()) + } + } + + private static class VariableMatch { + int start + int end + String variableId + } +} diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/docx/report/DocxComplexityReport.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/report/DocxComplexityReport.groovy new file mode 100644 index 00000000..901c77de --- /dev/null +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/report/DocxComplexityReport.groovy @@ -0,0 +1,48 @@ +//! --- +//! displayName: DOCX Complexity Report +//! category: Report +//! description: Creates a CSV report with statistics captured from the parsed DOCX templates, including text length and counts of paragraphs, tables, cells, images, shapes, headers and footers for each template. +//! sourceFormat: DOCX +//! --- +package com.quadient.migration.example.docx.report + +import com.quadient.migration.api.Migration +import com.quadient.migration.example.common.util.Csv +import java.nio.file.Path +import java.nio.file.Paths + +import static com.quadient.migration.example.common.util.InitMigration.initMigration + +def migration = initMigration(this.binding) +Path dstFile = Paths.get("report", "${migration.projectConfig.name}-docx-complexity-report.csv") + +run(migration, dstFile) + +static void run(Migration migration, Path documentObjectsDstPath) { + def objects = migration.documentObjectRepository.listAll().findAll { !it.internal } + + documentObjectsDstPath.toFile().createParentDirectories() + + documentObjectsDstPath.toFile().withWriter { writer -> + writer.writeLine("id,folder,name,title,textLength,paragraphs,tables,cells,images,shapes,headers,footers,byteSize") + objects.each { obj -> + + def builder = new StringBuilder() + builder.append(Csv.serialize(obj.id)) + builder.append("," + Csv.serialize(obj.targetFolder)) + builder.append("," + Csv.serialize(obj.name)) + builder.append("," + Csv.serialize((obj.customFields.get("Title") ?: ""))) + builder.append("," + (obj.customFields.get("textLength") ?: "0")) + builder.append("," + (obj.customFields.get("paragraphs") ?: "0")) + builder.append("," + (obj.customFields.get("tables") ?: "0")) + builder.append("," + (obj.customFields.get("cells") ?: "0")) + builder.append("," + (obj.customFields.get("images") ?: "0")) + builder.append("," + (obj.customFields.get("shapes") ?: "0")) + builder.append("," + (obj.customFields.get("headers") ?: "0")) + builder.append("," + (obj.customFields.get("footers") ?: "0")) + builder.append("," + (obj.customFields.get("size") ?: "0")) + writer.writeLine(builder.toString()) + } + } +} + diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/docx/style/DocxParagraphStyles.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/style/DocxParagraphStyles.groovy new file mode 100644 index 00000000..80be3f06 --- /dev/null +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/style/DocxParagraphStyles.groovy @@ -0,0 +1,96 @@ +package com.quadient.migration.example.docx.style + +import com.quadient.migration.api.Migration +import com.quadient.migration.api.dto.migrationmodel.builder.ParagraphStyleBuilder +import com.quadient.migration.api.dto.migrationmodel.builder.ParagraphStyleDefinitionBuilder +import com.quadient.migration.shared.Alignment +import groovy.transform.Field +import org.apache.poi.xwpf.usermodel.XWPFParagraph + +import static com.quadient.migration.example.docx.style.StyleChainResolver.resolveFirst +import static com.quadient.migration.example.docx.style.StyleChainResolver.resolveParagraphPropertyChain +import static com.quadient.migration.example.docx.util.DocxUtils.calculateSHA +import static com.quadient.migration.example.docx.util.DocxUtils.twipsToSize + +static String captureParagraphStyle(Migration migration, XWPFParagraph paragraph, String fileName, String styleId, String context) { + List pPrChain = resolveParagraphPropertyChain(paragraph) + Map lineSpacing = resolveFirst(pPrChain, StyleChainResolver.&resolveLineSpacing) + + // Attribute order and fallbacks feed the style id hash; changing them invalidates persisted mappings. + LinkedHashMap styleAttributes = [ + name : styleId, + alignment : resolveFirst(pPrChain, StyleChainResolver.&resolveAlignment) ?: "LEFT", + indentationLeft : resolveFirst(pPrChain, StyleChainResolver.&resolveIndentationLeft) ?: -1, + indentationRight : resolveFirst(pPrChain, StyleChainResolver.&resolveIndentationRight) ?: -1, + indentationFirstLine: resolveFirst(pPrChain, StyleChainResolver.&resolveIndentationFirstLine) ?: -1, + spacingBefore : resolveFirst(pPrChain, StyleChainResolver.&resolveSpacingBefore) ?: -1, + spacingAfter : resolveFirst(pPrChain, StyleChainResolver.&resolveSpacingAfter) ?: -1, + lineSpacingLine : lineSpacing?.line ?: -1, + lineSpacingRule : lineSpacing?.lineRule ?: "none", + ] + + if (paragraph.numID != null) { + String numFmt = paragraph.numFmt + ?: paragraph.document.numbering?.getNum(paragraph.numID)?.CTNum?.abstractNumId?.val + styleAttributes.listId = paragraph.numID.toString() + styleAttributes.listType = numFmt + styleAttributes.listLevel = paragraph.numIlvl.toString() + styleAttributes.name = "list${numFmt ? numFmt.capitalize() : ''}_${styleAttributes.name}" + } + + String styleName = styleAttributes.name.toString() + if (context) { + styleAttributes.context = context + styleName = context + '_' + styleName + } + + String sha = calculateSHA(styleAttributes) + if (migration.paragraphStyleRepository.find(sha) == null) { + ParagraphStyleDefinitionBuilder definition = new ParagraphStyleDefinitionBuilder() + .alignment(getAlignment(styleAttributes.alignment.toString())) + .leftIndent(twipsToSize(styleAttributes.indentationLeft as long)) + .rightIndent(twipsToSize(styleAttributes.indentationRight as long)) + .firstLineIndent(twipsToSize(styleAttributes.indentationFirstLine as long)) + .spaceBefore(twipsToSize(styleAttributes.spacingBefore as long)) + .spaceAfter(twipsToSize(styleAttributes.spacingAfter as long)) + applyLineSpacing(definition, styleAttributes.lineSpacingRule.toString(), styleAttributes.lineSpacingLine as long) + + ParagraphStyleBuilder paragraphStyleBuilder = new ParagraphStyleBuilder(sha) + .name(styleName + '_' + sha.substring(0, 3) + sha.takeRight(3)) + .originLocations([fileName]) + .definition(definition.build()) + styleAttributes.each { paragraphStyleBuilder.addCustomField(it.key.toString(), it.value.toString()) } + migration.paragraphStyleRepository.upsert(paragraphStyleBuilder.build()) + } + return sha +} + +static void applyLineSpacing(ParagraphStyleDefinitionBuilder builder, String lineRule, long line) { + switch (lineRule) { + case "auto": + // Word stores "auto" line spacing in 240ths of a line. + builder.multipleOfLineSpacing(line / 240.0) + break + case "atLeast": + builder.atLeastLineSpacing(twipsToSize(line)) + break + case "exact": + builder.exactLineSpacing(twipsToSize(line)) + break + default: + builder.additionalLineSpacing(twipsToSize(-1)) + } +} + +@Field +static final Map ALIGNMENTS = [ + left : Alignment.Left, + right : Alignment.Right, + center : Alignment.Center, + justify: Alignment.JustifyLeft, + both : Alignment.JustifyLeft, +] + +static Alignment getAlignment(String alignmentAttribute) { + return ALIGNMENTS.getOrDefault(alignmentAttribute.toLowerCase(), Alignment.Left) +} \ No newline at end of file diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/docx/style/DocxTableStyling.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/style/DocxTableStyling.groovy new file mode 100644 index 00000000..b4090725 --- /dev/null +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/style/DocxTableStyling.groovy @@ -0,0 +1,167 @@ +package com.quadient.migration.example.docx.style + +import com.quadient.migration.api.dto.migrationmodel.builder.TableBuilder +import com.quadient.migration.api.dto.migrationmodel.builder.documentcontent.BorderOptionsBuilder +import com.quadient.migration.shared.CellAlignment +import com.quadient.migration.shared.Color +import com.quadient.migration.shared.Size +import org.apache.poi.xwpf.usermodel.XWPFTable +import org.apache.poi.xwpf.usermodel.XWPFTableCell +import org.apache.poi.xwpf.usermodel.XWPFTableRow +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTBorder +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTShd +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblBorders + +import static com.quadient.migration.example.docx.util.DocxUtils.twipsToPoints + +static void applyColumnWidths(TableBuilder tableBuilder, XWPFTable table) { + def gridCols = table.getCTTbl()?.getTblGrid()?.getGridColList() + if (!gridCols) { + return + } + List widthsTwips = gridCols.collect { it.getW() } + long totalTwips = widthsTwips.sum() as long + if (totalTwips <= 0) { + return + } + List percentages = widthsTwips.collect { it / (double) totalTwips } + // Correct any floating point rounding drift so the fractions still sum to exactly 1.0 + percentages[-1] += 1.0 - percentages.sum() + + List columnWidths = percentages.collect { new TableBuilder.ColumnWidth(Size.ofMeters(0.001), it) } + tableBuilder.columnWidths(columnWidths) +} + +static void applyRowHeight(TableBuilder.Cell cellBuilder, XWPFTableRow row) { + def trHeight = row.ctRow?.trPr?.trHeightList?.find { it.isSetVal() } + if (trHeight == null || (trHeight.val as long) <= 0) { + return + } + Size height = Size.ofPoints(twipsToPoints(trHeight.val)) + switch (trHeight.isSetHRule() ? trHeight.HRule.toString() : "auto") { + case "exact": + cellBuilder.heightFixed(height) + break + case "atLeast": + cellBuilder.heightCustom(height, Size.ofPoints(height.toPoints() * 10)) + break + default: + break // auto (or unset): let the cell grow with its content + } +} + +static void applyCellStyling(TableBuilder.Cell cellBuilder, XWPFTableCell cell, XWPFTable table, + int rowIndex = 0, int columnIndex = 0, int rowCount = 1, int columnCount = 1) { + def tblPr = table.getCTTbl()?.getTblPr() + def tcPr = cell.getCTTc()?.getTcPr() + + def tblBorders = tblPr?.getTblBorders() + def styleBorders = tableStyleBorders(table) + def tcBorders = tcPr?.getTcBorders() + Color fill = resolveShdFill(tcPr?.getShd()) ?: resolveShdFill(tblPr?.getShd()) + def tblCellMar = tblPr?.getTblCellMar() + def tcMar = tcPr?.getTcMar() + CellAlignment alignment = resolveVerticalAlignment(tcPr) + + if (fill == null && tblBorders == null && styleBorders == null && tcBorders == null && tblCellMar == null && tcMar == null && alignment == null) { + return + } + if (alignment != null) { + cellBuilder.alignment(alignment) + } + cellBuilder.border { BorderOptionsBuilder builder -> + applyBorderLine(builder.&leftLine, firstUsable(tcBorders?.getLeft(), columnIndex == 0 ? tblBorders?.getLeft() : null, + columnIndex == 0 ? styleBorders?.getLeft() : null, columnIndex > 0 ? (tblBorders?.getInsideV() ?: styleBorders?.getInsideV()) : null)) + applyBorderLine(builder.&rightLine, firstUsable(tcBorders?.getRight(), columnIndex == columnCount - 1 ? tblBorders?.getRight() : null, + columnIndex == columnCount - 1 ? styleBorders?.getRight() : null)) + applyBorderLine(builder.&topLine, firstUsable(tcBorders?.getTop(), rowIndex == 0 ? tblBorders?.getTop() : null, + rowIndex == 0 ? styleBorders?.getTop() : null, rowIndex > 0 ? (tblBorders?.getInsideH() ?: styleBorders?.getInsideH()) : null)) + applyBorderLine(builder.&bottomLine, firstUsable(tcBorders?.getBottom(), rowIndex == rowCount - 1 ? tblBorders?.getBottom() : null, + rowIndex == rowCount - 1 ? styleBorders?.getBottom() : null)) + if (fill != null) { + builder.fill(fill) + } + applyPaddingSide(builder.&paddingLeft, tcMar?.getLeft(), tblCellMar?.getLeft()) + applyPaddingSide(builder.&paddingRight, tcMar?.getRight(), tblCellMar?.getRight()) + applyPaddingSide(builder.&paddingTop, tcMar?.getTop(), tblCellMar?.getTop()) + applyPaddingSide(builder.&paddingBottom, tcMar?.getBottom(), tblCellMar?.getBottom()) + } +} + +private static CellAlignment resolveVerticalAlignment(def tcPr) { + def vAlign = tcPr?.getVAlign()?.getVal() + if (vAlign == null) { + return null + } + switch (vAlign.toString()) { + case "center": return CellAlignment.Center + case "bottom": return CellAlignment.Bottom + case "top": return CellAlignment.Top + case "both": return CellAlignment.Top // "both" (justify) has no direct equivalent; top is the closest fallback + default: return null + } +} + +private static Color resolveShdFill(CTShd shd) { + if (shd == null) { + return null + } + return StyleChainResolver.toColor(shd.getFill()) ?: StyleChainResolver.toColor(shd.getColor()) +} + +private static CTBorder firstUsable(CTBorder... borders) { + return borders.find { isUsableBorder(it) } +} + +private static CTTblBorders tableStyleBorders(XWPFTable table) { + String styleId = table.CTTbl?.tblPr?.tblStyle?.val + def styles = table.rows.findResult { it.tableCells.findResult { cell -> cell.paragraphs[0]?.document?.styles } } + Set visited = [] + while (styleId && visited.add(styleId)) { + def style = styles?.getStyle(styleId)?.CTStyle + def borders = style?.tblPr?.tblBorders + if (borders != null) return borders + styleId = style?.basedOn?.val + } + return null +} + +private static boolean isUsableBorder(CTBorder border) { + if (border == null) { + return false + } + String style = border.getVal()?.toString() + return style && style != "none" && style != "nil" +} + +private static void applyBorderLine(Closure lineSetter, CTBorder border) { + if (!isUsableBorder(border)) { + return + } + Color color = StyleChainResolver.toColor(border.getColor()) + // w:sz is in eighths of a point + Size width = border.getSz() != null ? Size.ofPoints(border.getSz().doubleValue() / 8.0) : null + lineSetter(color, width) +} + +private static void applyPaddingSide(Closure paddingSetter, def cellSideMargin, def tableSideMargin) { + Size padding = resolveMarginSize(cellSideMargin) ?: resolveMarginSize(tableSideMargin) + if (padding != null) { + paddingSetter(padding) + } +} + +private static Size resolveMarginSize(def tblWidth) { + if (tblWidth == null || !tblWidth.isSetW()) { + return null + } + String type = tblWidth.getType()?.toString() + if (type == "nil") { + return Size.ofPoints(0) + } + if (type == "pct") { + return null // percentage-based margins aren't supported here + } + Double points = twipsToPoints(tblWidth.w) + return points != null ? Size.ofPoints(points) : null +} diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/docx/style/DocxTextStyles.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/style/DocxTextStyles.groovy new file mode 100644 index 00000000..64781a0e --- /dev/null +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/style/DocxTextStyles.groovy @@ -0,0 +1,56 @@ +package com.quadient.migration.example.docx.style + +import com.quadient.migration.api.Migration +import com.quadient.migration.api.dto.migrationmodel.builder.TextStyleBuilder +import com.quadient.migration.api.dto.migrationmodel.builder.TextStyleDefinitionBuilder +import com.quadient.migration.shared.Size +import org.apache.poi.xwpf.usermodel.XWPFRun + +import static com.quadient.migration.example.docx.style.StyleChainResolver.resolveFirst +import static com.quadient.migration.example.docx.style.StyleChainResolver.resolveRunPropertyChain +import static com.quadient.migration.example.docx.util.DocxUtils.calculateSHA + +static String captureTextStyle(Migration migration, XWPFRun run, String fileName, String fallbackStyleId, String context) { + String styleId = run.style ?: run.CTR.RPr?.RStyleList?.find()?.val ?: fallbackStyleId + // Effective formatting is resolved by walking the full chain: direct run properties, + // then the character/paragraph style and all of its basedOn ancestors, then docDefaults. + List rPrChain = resolveRunPropertyChain(run, fallbackStyleId) + + // Attribute order and fallbacks feed the style id hash; changing them invalidates persisted mappings. + LinkedHashMap styleAttributes = [ + name : styleId, + fontName: resolveFirst(rPrChain, StyleChainResolver.&resolveFontName), + fontSize: (resolveFirst(rPrChain, StyleChainResolver.&resolveFontSize) ?: -1) as double, + bold : resolveFirst(rPrChain, StyleChainResolver.&resolveBold) ?: false, + italic : resolveFirst(rPrChain, StyleChainResolver.&resolveItalic) ?: false, + color : resolveFirst(rPrChain, StyleChainResolver.&resolveColor), + ] + + String styleName = styleId + if (context) { + styleAttributes.context = context + styleName = context + '_' + styleName + } + + String sha = calculateSHA(styleAttributes) + if (migration.textStyleRepository.find(sha) == null) { + double fontSize = styleAttributes.fontSize as double + TextStyleDefinitionBuilder definition = new TextStyleDefinitionBuilder() + .fontFamily(styleAttributes.fontName?.toString()) + .size(Size.ofPoints(fontSize == -1 ? 10 : fontSize)) + .bold(styleAttributes.bold as boolean) + .italic(styleAttributes.italic as boolean) + String color = styleAttributes.color?.toString() + if (color && color.matches("(?i)[0-9A-F]{6}")) { + definition.foregroundColor('#' + color) + } + + TextStyleBuilder textStyleBuilder = new TextStyleBuilder(sha) + .name(styleName + '_' + sha.substring(0, 3) + sha.takeRight(3)) + .originLocations([fileName]) + .definition(definition.build()) + styleAttributes.each { textStyleBuilder.addCustomField(it.key.toString(), it.value.toString()) } + migration.textStyleRepository.upsert(textStyleBuilder.build()) + } + return sha +} diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/docx/style/StyleChainResolver.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/style/StyleChainResolver.groovy new file mode 100644 index 00000000..2a626b0a --- /dev/null +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/style/StyleChainResolver.groovy @@ -0,0 +1,133 @@ +package com.quadient.migration.example.docx.style + +import com.quadient.migration.shared.Color +import org.apache.poi.xwpf.usermodel.XWPFParagraph +import org.apache.poi.xwpf.usermodel.XWPFRun +import org.apache.poi.xwpf.usermodel.XWPFStyles +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPrBase +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRPr +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTStyle + +static List resolveRunPropertyChain(XWPFRun run, String fallbackStyleId) { + CTRPr directRPr = run.CTR.isSetRPr() ? run.CTR.RPr : null + XWPFStyles docStyles = run.document.styles + String styleId = directRPr?.RStyleList ? directRPr.RStyleList[0].val : (run.style ?: fallbackStyleId) + return buildPropertyChain(directRPr, docStyles, styleId, docStyles.defaultRunStyle?.RPr) { it.RPr } +} + +static List resolveParagraphPropertyChain(XWPFParagraph paragraph) { + CTPPrBase directPPr = paragraph.CTP.isSetPPr() ? paragraph.CTP.PPr : null + XWPFStyles docStyles = paragraph.document.styles + return buildPropertyChain(directPPr, docStyles, paragraph.styleID, docStyles.defaultParagraphStyle?.PPr) { it.PPr } +} + +static T resolveFirst(List chain, Closure extractor) { + return chain.findResult { extractor(it) } +} + +private static List buildPropertyChain(T directProperties, XWPFStyles docStyles, String styleId, T docDefaults, + Closure propertyExtractor) { + List chain = [] + if (directProperties != null) { + chain << directProperties + } + Set visitedStyleIds = [] + String currentStyleId = styleId + while (currentStyleId && visitedStyleIds.add(currentStyleId)) { + CTStyle ctStyle = docStyles.getStyle(currentStyleId)?.CTStyle + T properties = ctStyle != null ? propertyExtractor(ctStyle) : null + if (properties != null) { + chain << properties + } + currentStyleId = ctStyle?.isSetBasedOn() ? ctStyle.basedOn.val : null + } + if (docDefaults != null) { + chain << docDefaults + } + return chain +} + +static String resolveFontName(CTRPr rPr) { + if (rPr?.getRFontsList()) { + def font = rPr.getRFontsList().get(0) + return font.getAscii() ?: font.getHAnsi() ?: font.getEastAsia() + } + return null +} + +static Double resolveFontSize(CTRPr rPr) { + if (rPr?.getSzList()) { + def val = rPr.getSzList().get(0).getVal() + // XML 'sz' is in half-points + return val != null ? val.longValue() / 2.0 : null + } + return null +} + +static Boolean resolveBold(CTRPr rPr) { + return resolveOnOffFlag(rPr?.getBList()) +} + +static Boolean resolveItalic(CTRPr rPr) { + return resolveOnOffFlag(rPr?.getIList()) +} + +static String resolveColor(CTRPr rPr) { + if (rPr?.getColorList()) { + return colorValueToHex(rPr.getColorList().get(0).getVal()) + } + return null +} + +static String colorValueToHex(Object colorValue) { + if (colorValue instanceof byte[]) { + return colorValue.collect { String.format("%02X", it & 0xFF) }.join() + } + return colorValue?.toString() +} + +static Color toColor(Object colorValue) { + String hex = colorValueToHex(colorValue) + if (!hex || !hex.matches("(?i)[0-9A-F]{6}")) { + return null + } + return Color.fromHex('#' + hex) +} + +static String resolveAlignment(CTPPrBase pPr) { + return pPr?.getJc()?.getVal()?.toString() +} + +static Integer resolveIndentationLeft(CTPPrBase pPr) { + return (pPr?.getInd()?.isSetLeft()) ? pPr.getInd().getLeft() as int : null +} + +static Integer resolveIndentationRight(CTPPrBase pPr) { + return (pPr?.getInd()?.isSetRight()) ? pPr.getInd().getRight() as int : null +} + +static Integer resolveIndentationFirstLine(CTPPrBase pPr) { + return (pPr?.getInd()?.isSetFirstLine()) ? pPr.getInd().getFirstLine() as int : null +} + +static Integer resolveSpacingBefore(CTPPrBase pPr) { + return (pPr?.getSpacing()?.isSetBefore()) ? pPr.getSpacing().getBefore() as int : null +} + +static Integer resolveSpacingAfter(CTPPrBase pPr) { + return (pPr?.getSpacing()?.isSetAfter()) ? pPr.getSpacing().getAfter() as int : null +} + +static Map resolveLineSpacing(CTPPrBase pPr) { + if (!pPr?.getSpacing()?.isSetLine()) { + return null + } + String lineRule = pPr.getSpacing().isSetLineRule() ? pPr.getSpacing().getLineRule().toString() : "auto" + return [line: pPr.getSpacing().getLine() as long, lineRule: lineRule] +} + +private static Boolean resolveOnOffFlag(List onOffList) { + if (!onOffList) return null + def val = onOffList.get(0).getVal() + return val == null || !(val.toString() in ["false", "0", "off"]) +} diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/docx/util/DocxUtils.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/util/DocxUtils.groovy new file mode 100644 index 00000000..e6a7869e --- /dev/null +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/util/DocxUtils.groovy @@ -0,0 +1,64 @@ +package com.quadient.migration.example.docx.util + +import com.quadient.migration.api.dto.migrationmodel.DocumentContent +import com.quadient.migration.api.dto.migrationmodel.Paragraph +import com.quadient.migration.api.dto.migrationmodel.StringValue +import com.quadient.migration.shared.Size +import groovy.transform.Field +import org.apache.poi.xwpf.usermodel.XWPFTableCell + +import java.nio.charset.StandardCharsets +import java.security.MessageDigest + +@Field +static final double EMU_PER_POINT = 12700.0 + +static Double twipsToPoints(Object twips) { + if (twips == null) { + return null + } + try { + return twips.toString().toDouble() / 20.0 + } catch (NumberFormatException ignored) { + return null + } +} + +static Size twipsToSize(long twips) { + return Size.ofPoints(twips == -1 ? 0 : twips / 20.0) +} + +static Size emuToSize(long emu) { + return Size.ofPoints(emu / EMU_PER_POINT) +} + +static String sha256Hex(String value) { + return MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)) + .encodeHex() + .toString() +} + +static String calculateSHA(LinkedHashMap attributes) { + return sha256Hex(attributes.values().collect { it.toString() }.join()).toUpperCase() +} + +static boolean isHorizontallyMergedCell(XWPFTableCell cell) { + return cell.CTTc.tcPr?.HMerge?.val?.toString() == "continue" +} + +static String extractParagraphText(DocumentContent element) { + if (!(element instanceof Paragraph)) { + return null + } + return textValues(element) + .findAll { it instanceof StringValue } + .collect { (it as StringValue).value } + .join() +} + +private static List textValues(Paragraph paragraph) { + return (paragraph.content ?: []) + .findAll { it instanceof Paragraph.Text } + .collectMany { (it as Paragraph.Text).content ?: [] } +} diff --git a/migration-examples/src/main/resources/docx-project-config-template.toml b/migration-examples/src/main/resources/docx-project-config-template.toml new file mode 100644 index 00000000..97232130 --- /dev/null +++ b/migration-examples/src/main/resources/docx-project-config-template.toml @@ -0,0 +1,28 @@ +name = "" +baseTemplatePath = "icm://Interactive/StandardPackage/BaseTemplates/LetterheadBaseTemplate.wfd" +#styleDefinitionPath = "icm://Interactive/StandardPackage/CompanyStyles/Styles.wfd" +inputDataPath = "" +interactiveTenant = "StandardPackage" +#defaultTargetFolder = "" +inspireOutput = "Designer" # "Evolve", "Interactive", "Designer" +#sourceBaseTemplatePath = "icm://" +#defaultVariableStructure = "" +#defaultLanguage = "en_us" +#selectedDocumentObjectsFile = "" # e.g. "/migration-examples/document-objects/-document-objects" +#selectedDocumentObjects = [] +#subProjectId = "" + +[paths] +#images = "" +#fonts = "" +#documents = "" +#attachments = "" + +[context] +# Name of the table style applied to every parsed table. When omitted, tables +# keep only the formatting captured from the DOCX file. +#defaultTableStyleName = "" + +# Authoring conventions that should be parsed as variables in addition to native Word merge fields. +# ${...} and <<...>> +docxVariablePatterns = ['\$\{\s*([^}]+?)\s*}', '<<\s*([^>]+?)\s*>>'] From ba5f13152ae680f420899509c0acb2f15be3c272 Mon Sep 17 00:00:00 2001 From: "d.svitak" Date: Mon, 14 Sep 2026 10:15:52 +0200 Subject: [PATCH 2/3] Changelog mention --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06ab01df..014b0467 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ### Added +- New parser for DOCX format + ### Changed ### Fixed From 4c82b654134d3d9c7050dc8e18a4a8f6886a3871 Mon Sep 17 00:00:00 2001 From: "d.svitak" Date: Mon, 14 Sep 2026 13:44:55 +0200 Subject: [PATCH 3/3] add public samples, enrich the docx template toml file and add parsing of simple merge fields --- .../example/docx/parser/DocxMergeFields.groovy | 4 ++-- .../docx/parser/DocxParagraphParser.groovy | 17 +++++++++++++++++ .../docx-project-config-template.toml | 2 +- .../docx/01_notification_letter.docx | Bin 0 -> 24787 bytes .../docx/02_terms_and_conditions.docx | Bin 0 -> 11093 bytes .../docx/03_spousal_assignment.docx | Bin 0 -> 8940 bytes .../docx/04_invoice_statement.docx | Bin 0 -> 19362 bytes .../docx/05_regulatory_disclosure.docx | Bin 0 -> 8664 bytes .../exampleResources/docx/06_certificate.docx | Bin 0 -> 18593 bytes 9 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 migration-examples/src/main/resources/exampleResources/docx/01_notification_letter.docx create mode 100644 migration-examples/src/main/resources/exampleResources/docx/02_terms_and_conditions.docx create mode 100644 migration-examples/src/main/resources/exampleResources/docx/03_spousal_assignment.docx create mode 100644 migration-examples/src/main/resources/exampleResources/docx/04_invoice_statement.docx create mode 100644 migration-examples/src/main/resources/exampleResources/docx/05_regulatory_disclosure.docx create mode 100644 migration-examples/src/main/resources/exampleResources/docx/06_certificate.docx diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxMergeFields.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxMergeFields.groovy index c8d43343..87a94da9 100644 --- a/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxMergeFields.groovy +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxMergeFields.groovy @@ -331,8 +331,8 @@ private static boolean isFirstMatchCandidate(ParsedIfField field) { && (field.trueContent.first() as FieldTextPart).text } -private static void addMergeField(Migration migration, List textBuilders, String fileName, - String fieldInstruction, String textStyleId) { +static void addMergeField(Migration migration, List textBuilders, String fileName, + String fieldInstruction, String textStyleId) { String variableId = extractMergeFieldName(fieldInstruction) if (!variableId) { return diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxParagraphParser.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxParagraphParser.groovy index 0e0f29e9..7d759457 100644 --- a/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxParagraphParser.groovy +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/docx/parser/DocxParagraphParser.groovy @@ -4,8 +4,10 @@ import com.quadient.migration.api.Migration import com.quadient.migration.api.dto.migrationmodel.Paragraph import com.quadient.migration.api.dto.migrationmodel.builder.ParagraphBuilder import org.apache.poi.xwpf.usermodel.XWPFParagraph +import org.apache.poi.xwpf.usermodel.XWPFFieldRun import org.apache.poi.xwpf.usermodel.XWPFRun import org.apache.xmlbeans.XmlObject +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSimpleField import static com.quadient.migration.example.docx.style.DocxParagraphStyles.captureParagraphStyle import static com.quadient.migration.example.docx.style.DocxTextStyles.captureTextStyle @@ -22,6 +24,9 @@ class ParagraphContentCollector { private String textStyleId // The field state may be shared by consecutive paragraphs (cell, body flow) so that fields spanning paragraphs resolve. private final FieldParseState fieldState + // POI exposes the cached result runs inside w:fldSimple as ordinary paragraph runs. Remember fields already + // resolved so that the cached «name» text does not become literal content (or get emitted once per result run). + private final Set resolvedSimpleFields = Collections.newSetFromMap(new IdentityHashMap()) private boolean containsFieldCode = false ParagraphContentCollector(Migration migration, String fileName, Set excludedImageEmbedIds = Collections.emptySet(), @@ -39,6 +44,18 @@ class ParagraphContentCollector { DocxImages.processRunImages(migration, run, fileName, textBuilders, excludedImageEmbedIds) } + CTSimpleField simpleField = run instanceof XWPFFieldRun ? (run as XWPFFieldRun).CTField : null + if (simpleField != null && !resolvedSimpleFields.contains(simpleField)) { + String instruction = simpleField.instr + if (DocxMergeFields.isMergeField(instruction)) { + resolvedSimpleFields.add(simpleField) + flushText() + flushFields() + DocxMergeFields.addMergeField(migration, textBuilders, fileName, instruction, styleId) + return + } + } + List fieldChildren = DocxMergeFields.fieldChildren(run) if (fieldState.depth == 0 && fieldChildren.every { DocxMergeFields.isInstructionText(it) }) { flushFields() diff --git a/migration-examples/src/main/resources/docx-project-config-template.toml b/migration-examples/src/main/resources/docx-project-config-template.toml index 97232130..06bdd628 100644 --- a/migration-examples/src/main/resources/docx-project-config-template.toml +++ b/migration-examples/src/main/resources/docx-project-config-template.toml @@ -1,7 +1,7 @@ name = "" baseTemplatePath = "icm://Interactive/StandardPackage/BaseTemplates/LetterheadBaseTemplate.wfd" #styleDefinitionPath = "icm://Interactive/StandardPackage/CompanyStyles/Styles.wfd" -inputDataPath = "" +inputDataPath = "" # e.g. "/migration-examples/src/main/resources/exampleResources/docx" interactiveTenant = "StandardPackage" #defaultTargetFolder = "" inspireOutput = "Designer" # "Evolve", "Interactive", "Designer" diff --git a/migration-examples/src/main/resources/exampleResources/docx/01_notification_letter.docx b/migration-examples/src/main/resources/exampleResources/docx/01_notification_letter.docx new file mode 100644 index 0000000000000000000000000000000000000000..f290c132ef474c6cfd9a5fbce5ca403217570f0d GIT binary patch literal 24787 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 literal 0 HcmV?d00001 diff --git a/migration-examples/src/main/resources/exampleResources/docx/02_terms_and_conditions.docx b/migration-examples/src/main/resources/exampleResources/docx/02_terms_and_conditions.docx new file mode 100644 index 0000000000000000000000000000000000000000..ba63b129c34034f58f3b297dfdc0f0884014723d GIT binary patch literal 11093 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�I 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 literal 0 HcmV?d00001 diff --git a/migration-examples/src/main/resources/exampleResources/docx/05_regulatory_disclosure.docx b/migration-examples/src/main/resources/exampleResources/docx/05_regulatory_disclosure.docx new file mode 100644 index 0000000000000000000000000000000000000000..11a69254e637271e200b129cf1b9538733f1d392 GIT binary patch literal 8664 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 V1n2UI-7UDgLvVL@hv4pSr~BM{PM_}gjq&|_ zV`yfuI#@>Vx40YL1=NF4#F+UDw ziDD2l`%#)_lbS;tu(WmJO3Z)Y4>Us+V+xyUm%({}XMqcF)TxqFtyKM~tSvwAKws+1 zd*>z{ua4UNlbalo39s#I-#(SmIW@PHNP4m8-SSPrk64k6o1=6B1s54z>7I1?*xF#n z76!d2>J29JAFDUtQyjKHa3e|chcA7~&*121#6gylA0LKToTdtH{a{B9a1#1V)4~1#Bpwfj7zhjA?ISAtk7Hu&9n{gZ#+czkV8#KW?i7 z#XCIXt(g`)k2wKlN(O@jRdZxSi}((tvG;c{fXu%zDnSfR<2kTK3do0WKt|QJGqkX$ zr~94%FN6LM=E}bey)3pH$e{3F&i!Bgr`lzgyRq`5==CR8u$G{p)kP&xR~F3{-d?#E z7eO_TbcIKzXW}N^?9+uDH{&%ga1s>}Lfc{HAGLbbpW9pj2?1>dj4tyw+wf`ocSf(n zB%+kV{$Z-U~w+mrjFh6 zI*a;30%%}zU_$7hJ&ZI^)ZPw4_p$ld&WTj(w70Br(>1?d1J%{P^T(z0y4@)x05D4k z03ZV=#o5}z~UDYwG9X6y6%tJ0{PG=HzZ`*Lk(8WZ5z!J+~;elV_ zC-HJpSW?S@XR&AK_R~1Wz*w4QC%Gk23=`^)^T9Y?%rETBqzO@ce1^rq>`{l5X>$C;35uDAnZtx-`BUomiR*r8bru!y+YIM}yQ9Wd2D4DJKg}Z0q8T7$ zAWzntNPO3mMi6>8L7Hf(f_(mR2lIu2zqJ!_4-a8a{cw&T+8qVSr9NmkBhW#|=FL>( zj(7UeRAiiYdXpu;-Dda>EFF_3C^dCsg)OPQ0ZkSI)pPo2wry`2jokm#cPsxp=l+jQ?S65$oG2%(fGVgfuvh#6y6@+pz? zA_KC89MppieG1a8PO$ZAc0{fleA3ETgZj1|L)>T|YD^|$7jo8E$lN2+ib$$TzFT7w zJ#rfJ4&?iYWln945g+)C%3nZFFZjT-+`w)4% zLtblH%$ELw9FAX?G@Hv9NpSNWfVLSU5ZVn2R+oZ7^(K{LEBH}_Qo)P7YHqnFp06PN z+P&LVp`&uSqz6uqQR0eaCk{FFtK0#$ccJOM`COwdY5G%RQT723ZuyLn^H^~ z2^M4?XFPCEX=W98U;I_UoxlaB2~WfzfzMx(%(Y$ie7;bIVkv$tPBl{(i8Y0O(K zLv7f*@97eQG_sEc5x<8~6z>lAD&Rj?TJS@&pXkRm7IztMsJrMrArqB&jknapDt4Neld>V~5+aj1~WFJ1=_wW|U z556E9QKvXQK_|5Tlh9QG}FIV~t(8QnAN3YbxZoIw5McI1e7=J}*37hs-(;%hMfKyizX^D)?N9uN^-+jtHDJu-40I zO8Es1q%Do@9y2m{rC@({&hqwbYDokCk*`HQ(uuRU)2(>1Zg3k#K|S>}L{!7=`InMFZD0V)&EcprS-rD_Bo(p2+V7lsXy1M@GX!c3vTT6wY(*Me&RE*B7gJwwIQb3yTNZ6&$ZTmhW=G zsEVRdX`nBewGcG$3H$2H;&z`rqEsrf-xg@l{Y1%?WKJWbyL%lOT4Ah;0vXi{kML)m z!Je(TI@+ZtY(8Q`#_6q@m^cXzq$($Ebj*Kyy*rSy=zPXfC1C62N*8rvIMS4GPi8Kc zxO&myb}Uq>+c47)Ri7e9T-~|e92~Y|2i4CEoc^dlMr|b1q31`FTs``YJls8#+(Cc! zk{Lht)YNQdk_wl?b)JJ_WBQOWMD@z1@q0v+3mZo#7zq^fjbUrhOx=pu&8fNj=5z5A zzEE3Sa#q&MSWn(q6^x0fr60nd|^y)k?I ziOxmM_4TdRu=g{#t|by_>Fw^-` zN^Buk{rSL?<3=+9K4w3hJFosS?DL#@oym(TkbYt*b=#yu^{NseQjKK~ax52>j>*bM zq&=SIFGN^WTA|yJ8GHO=iZ?wMxALgdeU<~_>$L+XK*~G~QKk#;2WQ4gLPGS6xo?(U z(8J>vuPoaB+@*Oi)2BKWFQ58U?*Z*Mpd0t^x-ox~stE_EH95fn06YLR$Uk)BKMlyg zYRLaGCP9GS9{J%lEd}&;1kOcX{Og^!^S^xkAw>^etPD-KNuKtTxPinSZuh)e ztzJ|xg#DpO-*w-$OtZYxae#Nq)iA<`M$|Wl(y~B;WV79+*%f?WG*!%LKHCR`r|kNq zbdK67wufw^WW9D%ugxd%hxhhs60O-0>Pj9AMZKe%9F}IUyQ)^p*?qrfRlKj>kj<~9VvHlLDv+s5vHJk#g@gzuya zKB+?Kid37A`1;aqgBiC4*J#~2uSZNUvT2N)^;f?H_7CD90+?du>U4ep0Dk|W155uw zTb70frn>Z|mb%7xiv4Vc7mYdJq4 z-;<`GjraA zg*aw#X=!O=gO8hAgFJ3&agoMtlkfe-{{Cz&H8qt&I&*YsMb>b?wN<$H&6L!n?b>fha<1G}r>Uqs`5a31U3l+y+c366ETQRvDr# z4tv81v9TzgG3{fOQzlmHZK~?(T-@9=C?Rd_?K|7s4Hip1XL)mG=c~=~nwqb_mTH%l zRErgGP*7r{qc5h#>9Mh5)XFQ$%TIxwKRHPQb}l2s(avtU$#!Riz7r20|8#r1=1|t& z-rmvCvAn$8-``(fUmqtTaNA`!@^eTOyf+2>ke~6o12?UN=n@C&kzt1rKP2X1q3{vj*5iX1n{X5X)>gg zwY0o$kLQ4$tno7YN7B*RoG(|;%*+fzB)q%6-ofMI;CMW3+_<~BIW!mgM><!wVPi|o$XMIhn80Ag8TI~tpqcz@W5@_xNt=#j*v)A;o1(`MTq`+!!b*KfMmbx$~j zp(vNI^77)Ppa_znmXnp8%JP0|uJSS@Q8NMdy7jzchkIq0sL*WiO7$APw5+VGurRP} zsnyxq!lF!o7#j=g;qf5r_WV=1lKcK>ihPk&b4yDNtDvADa1F?!)@qHVJv`dt;%0h- z5a~6VWE2$*JLZ!V6cuk*tUF^^2?z*=hK7iUh=^kjAFmH0A|gIptukmQxw*T$a%%xs zT0voUbaWJ0H3Z)2a!K6F(9zL>jg1Y2LnPp4cfTtW%F}JEDPl77yTu?aJJS8T(mwtTTM+(Ad@PU8a5s~v$10n$m^eh-5LKR>^jSxHzJT-3nz{r&rP7=3z1#;v-sv2mqV`|ukN zGjs1yo@T34K|uir7uU5*TUAw+{y-#9dZZNJk`>P#=jIaE^slTur&)J|gonqAkOD;l zecJ`UoSdAKlaq)@@MBAz_xoE0motziF)@>7XBEbke7{@(m*V;1;o|Y-;=*RUIaz~_ zA`Ix5HZ>)eg+xT~*lYq;8Yd?w^Ms>f511AzwSjWW=zU*e9*7nc6!bekQPjXnm2U6d z=}HtH$5k&PA1Nv6g$p-Wuj;hAb$dc)=Gpdk&%rg2QGjzY#**ueii6|!@^IPH)3ds| zN+KNT;^LBAOeAhp?>o1=Tq0L|BZ!Ne2IMz8C@83)I*w6siLSOu~ZJsK=WYe=6Sld!}JnY#K4aG1woztV!xkdcWJ#BK%Y z>FMpL4rC287^o`lVuH!;m$RBvud;6Y~pgZWlq7y%d3F90Kk0 z=g)ReU9hmQ#9?OjPeXAOqkh;;3RG)Vx?l$?<1ZWE%jrYxn}KA8br#Oivb(>(e|Y$V z{_4@g{rSDMZS(wG8e=E}J8;W`z^0I5eSKX@N-7AmZOGjl9UVO- zB}G+L^ZLq(dRx0fou+A0U~g(ydKfeV=zx%TQbQj`E>o0}U4uxI9< zDcNahD;=KC6`HMu8!XJsGjnr-9kj16FY8{RpYH2-wZtwQj{9IDf>`C!pVOQ zf{Bk|Y?+J%JF|i=l-Upt@pn?DN@#CypUC6^vNJvoPVC6uZrU|)3TH$kP*78wLqw6Fgz$g+cDs;#u)p8& z_GJF5$C)8Tku;)AQ6<4jJYqV?uX0M6BSB7bPI%y z$L6)QHK1gFs#kt}ef=%W2nn^DD53^-G_3bEetjm?7cxjeP=zkd&?AAZCv%Bn%Vvk#;53m5IH$FO=0a$V}PuuWg%0vt}AaU^|*CwS>ODiij?$g`b zThjm#y%2&*i-R{sSTJXfvNqa&xOeet3BJx1bNU^Pf}1WMn3dvpgGy z8_mZ!b6HJI#(=stOcx6%EZ16{g@uKIQ{?REczb^@Eh#xRHpaou&d$XJ1rCn&;G5)l zGM~Zi8vE)Ca(7?|)GNSsLqS1VU0WNX&KUK>4eM|0z~gfI6&xN8?6ZZn_4(;(Msl)D zvBIy3k*3yG;MA9^H&*%)h=O~rBa%Td?J1{9w-2#=)_4W1b zt)soYKH}x=?b!IZ7mygFr6;<&x^{PWXJ>)BIsBgnVrgkDZq$F9)5RKJ$zG;J)rR4v=jS)dwOrU!q2-6GjGc)CJi;sqyPb%zWFO|A?1iEmmle$)z6_;9 zvco)&zs{JJp8)7|P`>b^&N|Nrb6Kx?yw>Ahv;@n9`@~B>I4(L96HTAoa%PR_4pvT~ zk=a>&RfP}uRZ~U4;6iuXpMTbB)k^R!o&BS4wSv2Kb(cN3RD#&SzR=_MLGO@FGaaeL z5V;6=mha)=1G2d;TO}(eD?tQ)?T~BUQr(yW(kJ$$nCT457R*{T`34fdsLtfJE?nY} zcjR|gS9UHCJV}C8epRh)?i?_+X0pO|K^RUg=XvS64QdbcUC1t5TJQZ#;UCGzc!7h4 zoKAB;bt=`kGCl=i@O6lmx^2O#HN*D9M(%OJ;qWf1EV?TZ&UeE@D{aX+5@Mcu3CTjqx)dz9x5g+Nq z;bWtF!Ktv}+=z+6Je?6>>NW!h>vq7%X95r&_K$PEK9K5B`_guC$yxvqDapPKm$|p( z%~PrCCVao^TPg|X3cMI?xHB0TiBqK7Ay`p}BsQ4jCZ6775P(7XGB!oVk5TQ+!1l{6 z_#oV_1!)r>J`hP=(k|3kh{ZC%YD?wG(30fStL5VU1qha$o;;tBF}YkOf`|nbGUMn2 zxdsfDI(Bbzd<>L~09!1DIooIW@qinLU}%UqfUQ+r^T0gh8kfvIZRrZ)5GPxV#AX44 zD}a)gWWa$VR3}}HV~3PVe`8x5I~NI0R+sGS7;5g*G$KQ>!#!9yyffdk#YU3pAi+m7 zqdp5^P}ytliXl+qv@4LX2sAF8MI8KIMU(G$ApGE&y#OMccUs)c!oab{d~0E*(p7%8 zmDNC_vJx-9rm_SDTYX!aeWAh3>}aJ64vlltOjZfK!4j4Y%~T@h6h>Dp%S*9+agqeR z!Vl#cKY0jnpG#-m(7uwCDP*neIF7-;zs50>8xp+1Vc)DWF;&0oucY(q(NrNM%LjM+ z(yoai_r9tlJEqqy4pTKUAfC=6+-Brb^bavpWxrQk#oYGA(RbA8&=)ONgK6iF7mJa# z&Pk$MH5OvaVr(jR=41CN+I`=p)$`R-WKl*Rd+*3C=A2M=996`(R~%Rr)Z)jlrPaIv z%O2AwC?~7%*stySo*vBl6-on>MFxv`DsBc9@fC5=! zk111&Xt((|+CSb-mHkM!)Qa`1(j^hX6@^Pl&$Kja0RU~qwox&Bgjmub?{xeQ*(68n zp8qx3@!OXnUc``TJ@Syi00Cp>2Y3`W36uhc@ZG&?E{~y(GfPdgN%_LXqN$?} zcr) zkipeo;F0h~bm*UbNy+SZTurnT?K>&HFMu60!t$MzT(^14tuuyb9uLT_G1v{iI9@ zQ;*InM!h;xRDY!3^emj!2Fwajnk%h-JYKG&h)!}INm8*y?d+x+S!*4tXcTo)T=*Hb z0ZnGM(g$xAS{<ZLQjL$F=Qlbq-k~klS7}WrH8iVns%u;WCwlO~=YawR$6rt71&0zOk z0!BVq5T!#rY`|ioxEZRtWA?PrG*TjA_6=k6+N;E{+KCry)EWyuE=6@u?VGRBWE@XP zhISz7^x15xtEXn%46x* zjC((458QjbBU$rSX{60GN!=EOv|!A4dSAR8Z}5qfim1K)G=Bo~R=|yDcPc-MTB)}x zV*C;Da<@o0D6yXTVcotf1=79u(uLFVvHnhEJM}DE5f>gePpy>;`$^t<8m|>4ookzT zqPv-vP1g0A9;jmf{jlQ5D->H8cuS7vdBb{#4c?2e>YeD~ zTJBw987GM#wNTaF#Hn|+H`7pE>bpqd$01Sspx5&}18S$rzMSTAdW5m47ZG{1Bd>>t zpNA$v1;lNcu3M^`5jL6^v9l*B_j|S+9T!4kc`X9*3oiTkRad-PuTLXEo+VZDI)y@C zWJ}}GnXU8cMLf09aYv&$cR&{aN*YZ0Te20Nfx8XJJATZ*EJHA4Pn*JD8E!doc&~9( zi}3rBWBBd*s4X?*v=sBZ)}pSXtwUB?Xk4ZkG|X|Ql9Ks9v?<{d$Ls6!jX-Yp$NciT zLE#e0SmT9Xzi4r%8_63`pJx8Re+5bn0rm5Tc=W92<}Sn(;~nu} zbnC2W7T^eQ$gUTj%pgh2c!UeG@G1+=DqEjzK&3JR|MZF;URRl3JH?Bxf?-psO91J6 zfSXM!CgYHtr`MX;Ry<RzvdTH(t`qubXbpGhZy5w zec}-$3q$>qvCRhij?i!8z9Vcha++>FwzT%uB76S5SM;g(E&wq`j$?~{yq+1sua8u1 zo!qhz@-${-x4dsN0Gv#*2b?;@N6zD_K|R^9B`AgD&tNA~hmJ8b z2*31-!rV2t9YibB*w^wGBr~kc9B`8fQ34+^a8^Quv~9&636h+7-uUM*zNU~>n;7%d zVOpcFT`1>ihGyvuZN{2B3}WOP91i_TH!)k~GGF9uo0_mdFbeOphpfF?OK{t_P=9IF z82vy6U8nD)zgJHW>Mr$ss)CSOxi8a>pmY2X>GM zgu!Hu%$%^ZWy`E`X*pa5hM+s>{N^@Wj(D_shs5Dzlc2ic zSOW`@x)Omj-LOsHqB+1w-zHfCKe1yQf)$$qYK(lpmw=dZMBNbb8BdU;e2Zm{0uLf} zCNSNUayZ;njg)6}8O0LR@Tft}4sK6;^amUSQv`h{eIZY7DQEa3s%`uUMTyq8$AqaH zge8o@HzwD>yxgF8VUe%yPOe3H<+G%@?@*IrpB=SNWjdP1QVpZKa+-B*RX+RPTm?vA z$l?GE?KY`(Em(#)s(-a#dS?TOvDj>zxK%iO{kyp3$`cmnCf4KFlB^u{JLlA-aC)IY z9fm$>o2EQvPLF?UfU%{dwS7d)kTA$7NY{tULA@L-t6qRE=2~oAI6QSFY zwTc5hW2C8wmT;QK6nY#zCh%hJ;zu`Y7SJz`I(<45C6E`M`TIb;T;a-K95fS3iK|uZw0a`xb+!L zJ7=aVuAgAK+sHbCL5l+49#5hg-k)V|Q9LXP4>}o^NQ+-2muTN+%Hn^1;!$k0G&!-O zGa>g6y)AIbLq$}!(m7K)6Apg&EOF_rG5YbD>sB4>L8=Ol_seURx3r=)&K>-1m3-xj zuchVbnkL?W!y5K+dFE5s)#tMeZsFjJ9Iqw$&Xp&IMUU@KN16KF9J1!l$Ig+a>izv~ zVFxs~9I_`jxlTJPlZzV^x^x-9N2g;+)ThYm`}5rOhJHa>7kNQk#js;a@e&$y}dSaC`4Y2ZzNOGI%&an3PU!t`-N#gX(o z@W}b!jgHJ`juSEj0HDJM06_dN_P2L%wJ`jL$uXKCZok5gGH|Y5zu~(#b4CDBLX-sMIzE%IZSrMvLHq5kF)M5QYMZRk z^&`O4EAjgQRB5hCR)mmiQ!dQV#x<*CUl3d-Sw7Oc(TFU64dLFC=3pEvas~^Ei>>8(eF?D1RsxcmOq;z_Cwq{7BfH2pGUIV$+Yl8SJ5N{o{xPD#WvI0lW~}uKeHGGsiy-g@vWZ%p)penyy=6 z?Au(65HOJ+$$7kq{2ZLQS!-0i{+K3dj{(Cpv_3|cza`Sx{A77!(EeDtY+kfNtRMWs zSUR2j5Km_n&}+g%{S)c{D(Ff^Z>KTsIs8&0zvS9K%WE3fm_;{B6>VyEkx{cc*h_or zMqMiJ^p3AWk>wD9h+h8ke)mS8nRq;}^dP#z@CqfXkQ6!SjTk|%*?Fh|N_qdiTvwmR z$nuU_%m5OxzZH7U18rMGZCA*8C1p=)V@~S_nB@BLmzhS+$xC(o!`<=^4o^x@PZvs1 za>)zaNa7?h;d5^n3_dMU`m!Xc`pCGc`co*W`Z2Jn`m&&@`Y;YG11JL~kz^9c>3%^C zpIH1n^+N8f&fYkBB3ix0YVD1veYRw4}2v&nvsjH&co;el4)vD%ruKMSfOcVe|f01_RjBrBxkrI(h3m>8=;hb zQuU>kM%upHJ0~$FVG=_&>h~v7BE$J?%|!4TzGEkJy+PrA zNSNQHs-q>ElbBN&sEIV z%FT#rD#NJjof8V(LT*6sVjZ!wW-=LY3*Mx+KqD<-{It=RXFZ*HNH z0o>Vur|H{0qi!)_r)Il2bQqX7b?^kDb7MD{_6U>whj7A8VJ$T7%l=to(D%!X@%N)5 zU(8%+DQ)iCyPlaNB8XN40`ip5pCr()1hkw~Ei;5ZltRpW10=ABF2@ z73bet_qV03|`z0ExyJDX>ozkQ&8V~HBz zR*$dl1S#`f7pj7E!{sMP5>0*nRlV){t7`l4ta2b)mK(8X7wuddJFJc4TepWRf44{T z*KUu;FWqgNIuH1qkQNV?zq58u^wAlVE}zFx=E|AQqEc?iQRQ^X{`rp}lt?WD#H)4FU22#RwwgEQTen`MGExM^;iDO&%Nm2EA2a@pwhxr_aj0 zefa-fg#aT}0g2_T{4Kt=lxTQtE?Z2f6$hoNA`rZ+l&E`X4zP0ue328YfMc^-hM;K^ z&Yo^BUL!O!ye(>tZm{@xV9vLC0~IbMamem!HNTD$&&bk5i6Y=A+Knl09P=`Ku8Pq8 zoy+h8?yB(j_RTcvXZQaY%1#McX{qg;T+x4Jv1@ZpX2oOw%>Ecqh1#(L>mON7D~>X4 zee<80+vazRKH!LzX&qaD*^*{NY2vcRzhq?<{fgol|D8E0@01j<{Y&<@PE2Po=n@cs zu!@T)fxl&9-ly!A`oVj%Z#A;yaYPZCqCK1*)0QOV74@G~lB%~)NdM9fW{i{m^0}h| zO;Ors?o0iSWSy&I0!tz-%uQ+QjnSNrXLAE*F4L<04+ITuY67sAyFI02=#{||=gH+I z0=0G+iMQ=J`)Dh|9XD;V=qV$p*RxugK%4fFskx(@ypF`wcU9|*!;j@Fp@qUd+LkKHu!#Hs{ateyKRmbg5%0SDuG=+qVd}BbF{01P`$N+$x)N z3#3zno2ZVuas8rJYpo-NTfp1fcp&xEiVR=TBvbj}L4k2N!%|P^@~tzy$hp z|IhU^emX18kqYkD z^_~mKbfL#HmpN|SW8(+c$D*Z5&l0pl>W@zwl*UUB zWscTar$n_aJ8?T#KowOj_|{EDCb(-QOVaFg?%7wQ@4ich5wF;7Z(R=U)@bF-sCgyx?Zgso0p-z@ZH?RR*U zx%ccyWQhgYs)D!0vQ{XOJI~x_yy+aW$=o%9IWC0>%MG^Wd0Oi?=EMTdOP0g}N6KBa zWaT;P;$OBf8-BOSh~jGn^FJ)weBoRss$R^0?ppafwdV1ihl<#*rgz7z-94JlpyU7I>w3N@8u8Q9$Z-|LIbZRU8^E0$m8^z9Tn*Z9D=We-K8aHCK@;jgx;t3J-zWUQ6@<1?Nog}=&j9`oxKvEV1 znHT(n<&yUX5g8XOJepD>x{j#>hdqn42p=zgz1W8y-@3w&OW$J!u&l&Qogf~sTbk8l z!MT~q-Jg4@yeJpRs~%PKPhIbL^%p=PeR#6AK6 zaGNjLF(n^^h#RZ;qGo5@t>oor^b|=9JHm`ZQoasyZR_&_TeZ2(9$Ebhn^NfmM-AS9 zss?)vo(=E9C_v^+nCR-LIqCv>4-6x|qikSn+&w-}ZC`aP`%R>5mVMje%dZc~H7}Nf zXgh&yod9XhUpf1wSzP+~D^#Rg#S#}fWXO1DO?+F(-)zI(u*3-!sKD9l9p5J;mlJex zzE;Y?iFk#uJzBCdGv?^_`wcQ2blJeOz*~GRa*#597-39}VIjRrR&^jVWgORH3WMFmhimy;$FGCrGDvlm~bSR z^K~l*Rl7E_m*gPUe?y{GR6}xCeTSBG0tKf}%9h~s(sw1$@S2n$sjI!$*0GM(ql7V& z;|(@KUj1vnngyt;H@N?_6M{+tBccN2TYn(wAOmAXfB_;hcGfob^!nCz|DYORwDJE2 zhyXb*Ja)>;j{z<4Jn0v{NF@|)Rq_&mJnhnqT>PiSlI9FSeGJC5Q+8CX@lqIqj4U&~ zlj^v0$J@cAL5+NXvVV2WEF~Q$M(T2=0BbS{qy%h1_5ik7Sv zjtjFPM89a2mictz6_p_F0aa-A9wCpcjVZ9%J8qfR6-Wh$Lf0m~X!_%u7N0*h(2sCJ|by8Dw89j<&us1vF+eENTrD&S<#y~Ra8Hy21Btw3 zl&htH)?+@nImRx&@GX6UKo$Z`Txq{DX^&Tn8twlm#n_VDk8FYD*asv>q`#7*u8qxa z8TP-_2%I)xN|zh?9j??ya)~H=RzCAl5W+HChWe*((k#GuWQi0b(sBv1-g_djjwl$H z_jJ@vxK_XSwqs$4;wig~a%PT69F=8yez?x?Np6j9-V`O5p?-2+Cxw6^f8pf?KC|8N zFs~;%`a6u_a^GPE7FDuvO5 zrC4h!CoT$7)xj{mj2<_qc!%A1*ieN+{1%R@(U@E^pj#K9xI_yQO+5Q;$-= zeon2=IB)$)rXZlSK+EPoe@Nw@i}GjwA3m!hBmS=l{`CV7e}V!4#XuA4FQ0q(JK*0V z-~R+10;>Lhi-Z3=_`e1R{s{~K>;X5#|Gz>7|B31U9t!p+uCGY{m%y;UcjoVR>;6Q+ zO!x@8Eww2>cTqn)(m$e>oQXJN(~I68?mT0^WD{ z$8`RAuJCu@zZ=be0;_WW0sJ=;`tKP2ZuR|%VVUO-4F6&S{vG~b)&HN+Kx5zw0Pt_d nz~90DRb2lSJe&V7;C~2j8F2_8g#vF<0)GU6L@q4&`|1AxIlkEm literal 0 HcmV?d00001