diff options
| author | Michael Williamson <mike@zwobble.org> | 2017-02-19 18:16:52 +0000 |
|---|---|---|
| committer | Michael Williamson <mike@zwobble.org> | 2017-02-19 19:21:02 +0000 |
| commit | b31c5a4b4ccb835a583efa33cee178e122d564de (patch) | |
| tree | 2dd68868d5cc1bbeaa5283798122b4cc4e5c2b2d /src/main/java/org/zwobble | |
| parent | 2e819654db985bf8a7e7ed76e0a43dc2f46eec8d (diff) | |
Handle hyperlinks created with complex fields
Diffstat (limited to 'src/main/java/org/zwobble')
3 files changed, 573 insertions, 432 deletions
diff --git a/src/main/java/org/zwobble/mammoth/internal/docx/BodyXmlReader.java b/src/main/java/org/zwobble/mammoth/internal/docx/BodyXmlReader.java index 4cbdda7..ace6628 100644 --- a/src/main/java/org/zwobble/mammoth/internal/docx/BodyXmlReader.java +++ b/src/main/java/org/zwobble/mammoth/internal/docx/BodyXmlReader.java @@ -1,34 +1,10 @@ package org.zwobble.mammoth.internal.docx; import org.zwobble.mammoth.internal.archives.Archive; -import org.zwobble.mammoth.internal.archives.Archives; -import org.zwobble.mammoth.internal.documents.*; -import org.zwobble.mammoth.internal.results.InternalResult; -import org.zwobble.mammoth.internal.util.Casts; -import org.zwobble.mammoth.internal.util.InputStreamSupplier; -import org.zwobble.mammoth.internal.util.Lists; -import org.zwobble.mammoth.internal.util.Optionals; import org.zwobble.mammoth.internal.xml.XmlElement; -import org.zwobble.mammoth.internal.xml.XmlElementLike; -import org.zwobble.mammoth.internal.xml.XmlElementList; import org.zwobble.mammoth.internal.xml.XmlNode; -import java.util.*; -import java.util.function.Function; - -import static org.zwobble.mammoth.internal.docx.ReadResult.EMPTY_SUCCESS; -import static org.zwobble.mammoth.internal.docx.ReadResult.success; -import static org.zwobble.mammoth.internal.docx.Uris.uriToZipEntryName; -import static org.zwobble.mammoth.internal.util.Iterables.lazyFilter; -import static org.zwobble.mammoth.internal.util.Lists.list; -import static org.zwobble.mammoth.internal.util.Maps.entry; -import static org.zwobble.mammoth.internal.util.Maps.lookup; -import static org.zwobble.mammoth.internal.util.Sets.set; - public class BodyXmlReader { - private static final Set<String> IMAGE_TYPES_SUPPORTED_BY_BROWSERS = set( - "image/png", "image/gif", "image/jpeg", "image/svg+xml", "image/tiff"); - private final Styles styles; private final Numbering numbering; private final Relationships relationships; @@ -42,7 +18,8 @@ public class BodyXmlReader { Relationships relationships, ContentTypes contentTypes, Archive file, - FileReader fileReader) + FileReader fileReader + ) { this.styles = styles; this.numbering = numbering; @@ -52,414 +29,25 @@ public class BodyXmlReader { this.fileReader = fileReader; } - public ReadResult readElement(XmlElement element) { - switch (element.getName()) { - case "w:t": - return success(new Text(element.innerText())); - case "w:r": - return readRun(element); - case "w:p": - return readParagraph(element); - - case "w:tab": - return success(Tab.TAB); - case "w:br": - return readBreak(element); - - case "w:tbl": - return readTable(element); - case "w:tr": - return readElements(element.getChildren()).map(TableRow::new); - case "w:tc": - return readTableCell(element); - - case "w:hyperlink": - return readHyperlink(element); - case "w:bookmarkStart": - return readBookmark(element); - case "w:footnoteReference": - return readNoteReference(NoteType.FOOTNOTE, element); - case "w:endnoteReference": - return readNoteReference(NoteType.ENDNOTE, element); - case "w:commentReference": - return readCommentReference(element); - - case "w:pict": - return readPict(element); - - case "v:imagedata": - return readImagedata(element); - - case "wp:inline": - case "wp:anchor": - return readInline(element); - - case "w:sdt": - return readSdt(element); - - case "w:ins": - case "w:smartTag": - case "w:drawing": - case "v:roundrect": - case "v:shape": - case "v:textbox": - case "w:txbxContent": - return readElements(element.getChildren()); - - case "office-word:wrap": - case "v:shadow": - case "v:shapetype": - case "w:bookmarkEnd": - case "w:sectPr": - case "w:proofErr": - case "w:lastRenderedPageBreak": - case "w:commentRangeStart": - case "w:commentRangeEnd": - case "w:del": - case "w:footnoteRef": - case "w:endnoteRef": - case "w:annotationRef": - case "w:pPr": - case "w:rPr": - case "w:tblPr": - case "w:tblGrid": - case "w:tcPr": - return EMPTY_SUCCESS; - - default: - String warning = "An unrecognised element was ignored: " + element.getName(); - return ReadResult.emptyWithWarning(warning); - } - } - - private ReadResult readRun(XmlElement element) { - XmlElementLike properties = element.findChildOrEmpty("w:rPr"); - return ReadResult.map( - readRunStyle(properties), - readElements(element.getChildren()), - (style, children) -> new Run( - isBold(properties), - isItalic(properties), - isUnderline(properties), - isStrikethrough(properties), - readVerticalAlignment(properties), - style, - children)); - } - - private boolean isBold(XmlElementLike properties) { - return readBooleanElement(properties, "w:b"); - } - - private boolean isItalic(XmlElementLike properties) { - return readBooleanElement(properties, "w:i"); - } - - private boolean isUnderline(XmlElementLike properties) { - return readBooleanElement(properties, "w:u"); - } - - private boolean isStrikethrough(XmlElementLike properties) { - return readBooleanElement(properties, "w:strike"); - } - - private boolean readBooleanElement(XmlElementLike properties, String tagName) { - return properties.findChild(tagName) - .map(child -> child.getAttributeOrNone("w:val") - .map(value -> !value.equals("false") && !value.equals("0")) - .orElse(true)) - .orElse(false); - } - - private VerticalAlignment readVerticalAlignment(XmlElementLike properties) { - String verticalAlignment = readVal(properties, "w:vertAlign").orElse(""); - switch (verticalAlignment) { - case "superscript": - return VerticalAlignment.SUPERSCRIPT; - case "subscript": - return VerticalAlignment.SUBSCRIPT; - default: - // TODO: warn if set? - return VerticalAlignment.BASELINE; - } - } - - private InternalResult<Optional<Style>> readRunStyle(XmlElementLike properties) { - return readStyle(properties, "w:rStyle", "Run", styles::findCharacterStyleById); - } - - public ReadResult readElements(Iterable<XmlNode> nodes) { - return ReadResult.flatMap(lazyFilter(nodes, XmlElement.class), this::readElement); + ReadResult readElements(Iterable<XmlNode> nodes) { + return new StatefulBodyXmlReader( + styles, + numbering, + relationships, + contentTypes, + file, + fileReader + ).readElements(nodes); } - private ReadResult readParagraph(XmlElement element) { - XmlElementLike properties = element.findChildOrEmpty("w:pPr"); - Optional<NumberingLevel> numbering = readNumbering(properties); - return ReadResult.map( - readParagraphStyle(properties), - readElements(element.getChildren()), - (style, children) -> new Paragraph(style, numbering, children)).appendExtra(); - } - - private InternalResult<Optional<Style>> readParagraphStyle(XmlElementLike properties) { - return readStyle(properties, "w:pStyle", "Paragraph", styles::findParagraphStyleById); - } - - private InternalResult<Optional<Style>> readStyle( - XmlElementLike properties, - String styleTagName, - String styleType, - Function<String, Optional<Style>> findStyleById) - { - return readVal(properties, styleTagName) - .map(styleId -> findStyleById(styleType, styleId, findStyleById)) - .orElse(InternalResult.empty()); - } - - private InternalResult<Optional<Style>> findStyleById( - String styleType, - String styleId, - Function<String, Optional<Style>> findStyleById) - { - Optional<Style> style = findStyleById.apply(styleId); - if (style.isPresent()) { - return InternalResult.success(style); - } else { - return new InternalResult<>( - Optional.of(new Style(styleId, Optional.empty())), - list(styleType + " style with ID " + styleId + " was referenced but not defined in the document")); - } - - } - - private Optional<NumberingLevel> readNumbering(XmlElementLike properties) { - XmlElementLike numberingProperties = properties.findChildOrEmpty("w:numPr"); - return Optionals.flatMap( - readVal(numberingProperties, "w:numId"), - readVal(numberingProperties, "w:ilvl"), - numbering::findLevel); - } - - private ReadResult readBreak(XmlElement element) { - String breakType = element.getAttributeOrNone("w:type").orElse("textWrapping"); - switch (breakType) { - case "textWrapping": - return success(Break.LINE_BREAK); - case "page": - return success(Break.PAGE_BREAK); - case "column": - return success(Break.COLUMN_BREAK); - default: - return ReadResult.emptyWithWarning("Unsupported break type: " + breakType); - } - } - - private ReadResult readTable(XmlElement element) { - return readElements(element.getChildren()) - .flatMap(this::calculateRowspans) - .map(Table::new); - } - - private ReadResult calculateRowspans(List<DocumentElement> rows) { - Optional<String> error = checkTableRows(rows); - if (error.isPresent()) { - return ReadResult.withWarning(rows, error.get()); - } - - Map<Map.Entry<Integer, Integer>, Integer> rowspans = new HashMap<>(); - Set<Map.Entry<Integer, Integer>> merged = new HashSet<>(); - - Map<Integer, Map.Entry<Integer, Integer>> lastCellForColumn = new HashMap<>(); - for (int rowIndex = 0; rowIndex < rows.size(); rowIndex += 1) { - TableRow row = (TableRow) rows.get(rowIndex); - int columnIndex = 0; - for (int cellIndex = 0; cellIndex < row.getChildren().size(); cellIndex += 1) { - UnmergedTableCell cell = (UnmergedTableCell) row.getChildren().get(cellIndex); - Optional<Map.Entry<Integer, Integer>> spanningCell = lookup(lastCellForColumn, columnIndex); - Map.Entry<Integer, Integer> position = entry(rowIndex, cellIndex); - if (cell.vmerge && spanningCell.isPresent()) { - rowspans.put(spanningCell.get(), lookup(rowspans, spanningCell.get()).get() + 1); - merged.add(position); - } else { - lastCellForColumn.put(columnIndex, position); - rowspans.put(position, 1); - } - columnIndex += cell.colspan; - } - } - - return success(Lists.eagerMapWithIndex(rows, (rowIndex, rowElement) -> { - TableRow row = (TableRow) rowElement; - - List<DocumentElement> mergedCells = new ArrayList<>(); - for (int cellIndex = 0; cellIndex < row.getChildren().size(); cellIndex += 1) { - UnmergedTableCell cell = (UnmergedTableCell) row.getChildren().get(cellIndex); - Map.Entry<Integer, Integer> position = entry(rowIndex, cellIndex); - if (!merged.contains(position)) { - mergedCells.add(new TableCell( - lookup(rowspans, position).get(), - cell.colspan, - cell.children - )); - } - } - - return new TableRow(mergedCells); - })); - } - - private Optional<String> checkTableRows(List<DocumentElement> rows) { - for (DocumentElement rowElement : rows) { - Optional<TableRow> row = Casts.tryCast(TableRow.class, rowElement); - if (!row.isPresent()) { - return Optional.of("unexpected non-row element in table, cell merging may be incorrect"); - } else { - for (DocumentElement cell : row.get().getChildren()) { - if (!(cell instanceof UnmergedTableCell)) { - return Optional.of("unexpected non-cell element in table row, cell merging may be incorrect"); - } - } - } - } - return Optional.empty(); - } - - private ReadResult readTableCell(XmlElement element) { - XmlElementLike properties = element.findChildOrEmpty("w:tcPr"); - Optional<String> gridSpan = properties - .findChildOrEmpty("w:gridSpan") - .getAttributeOrNone("w:val"); - int colspan = gridSpan.map(Integer::parseInt).orElse(1); - return readElements(element.getChildren()) - .map(children -> new UnmergedTableCell(readVmerge(properties), colspan, children)); - } - - private boolean readVmerge(XmlElementLike properties) { - return properties.findChild("w:vMerge") - .map(element -> element.getAttributeOrNone("w:val").map(val -> val.equals("continue")).orElse(true)) - .orElse(false); - } - - private static class UnmergedTableCell implements DocumentElement { - private final boolean vmerge; - private final int colspan; - private final List<DocumentElement> children; - - private UnmergedTableCell(boolean vmerge, int colspan, List<DocumentElement> children) { - this.vmerge = vmerge; - this.colspan = colspan; - this.children = children; - } - - @Override - public <T> T accept(DocumentElementVisitor<T> visitor) { - return visitor.visit(new TableCell(1, colspan, children)); - } - } - - private ReadResult readHyperlink(XmlElement element) { - Optional<String> relationshipId = element.getAttributeOrNone("r:id"); - Optional<String> anchor = element.getAttributeOrNone("w:anchor"); - ReadResult childrenResult = readElements(element.getChildren()); - if (relationshipId.isPresent()) { - return childrenResult.map(children -> Hyperlink.href( - relationships.findRelationshipById(relationshipId.get()).getTarget(), children)); - } else if (anchor.isPresent()) { - return childrenResult.map(children -> Hyperlink.anchor( - anchor.get(), children)); - } else { - return childrenResult; - } - } - - private ReadResult readBookmark(XmlElement element) { - String name = element.getAttribute("w:name"); - if (name.equals("_GoBack")) { - return ReadResult.EMPTY_SUCCESS; - } else { - return success(new Bookmark(name)); - } - } - - private ReadResult readNoteReference(NoteType noteType, XmlElement element) { - String noteId = element.getAttribute("w:id"); - return success(new NoteReference(noteType, noteId)); - } - - private ReadResult readCommentReference(XmlElement element) { - String commentId = element.getAttribute("w:id"); - return success(new CommentReference(commentId)); - } - - private ReadResult readPict(XmlElement element) { - return readElements(element.getChildren()).toExtra(); - } - - private ReadResult readImagedata(XmlElement element) { - return element.getAttributeOrNone("r:id") - .map(relationshipId -> { - Optional<String> title = element.getAttributeOrNone("o:title"); - String imagePath = relationshipIdToDocxPath(relationshipId); - return readImage(imagePath, title, () -> Archives.getInputStream(file, imagePath)); - }) - .orElse(ReadResult.emptyWithWarning("A v:imagedata element without a relationship ID was ignored")); - } - - private ReadResult readInline(XmlElement element) { - XmlElementLike properties = element.findChildOrEmpty("wp:docPr"); - Optional<String> altText = Optionals.first( - properties.getAttributeOrNone("descr").filter(description -> !description.trim().isEmpty()), - properties.getAttributeOrNone("title") - ); - XmlElementList blips = element.findChildren("a:graphic") - .findChildren("a:graphicData") - .findChildren("pic:pic") - .findChildren("pic:blipFill") - .findChildren("a:blip"); - return readBlips(blips, altText); - } - - private ReadResult readBlips(XmlElementList blips, Optional<String> altText) { - return ReadResult.flatMap(blips, blip -> readBlip(blip, altText)); - } - - private ReadResult readBlip(XmlElement blip, Optional<String> altText) { - Optional<String> embedRelationshipId = blip.getAttributeOrNone("r:embed"); - Optional<String> linkRelationshipId = blip.getAttributeOrNone("r:link"); - if (embedRelationshipId.isPresent()) { - String imagePath = relationshipIdToDocxPath(embedRelationshipId.get()); - return readImage(imagePath, altText, () -> Archives.getInputStream(file, imagePath)); - } else if (linkRelationshipId.isPresent()) { - String imagePath = relationships.findRelationshipById(linkRelationshipId.get()).getTarget(); - return readImage(imagePath, altText, () -> fileReader.getInputStream(imagePath)); - } else { - // TODO: emit warning - return ReadResult.EMPTY_SUCCESS; - } - } - - private ReadResult readImage(String imagePath, Optional<String> altText, InputStreamSupplier open) { - Optional<String> contentType = contentTypes.findContentType(imagePath); - Image image = new Image(altText, contentType, open); - - String contentTypeString = contentType.orElse("(unknown)"); - if (IMAGE_TYPES_SUPPORTED_BY_BROWSERS.contains(contentTypeString)) { - return success(image); - } else { - return ReadResult.withWarning(image, "Image of type " + contentTypeString + " is unlikely to display in web browsers"); - } - } - - private ReadResult readSdt(XmlElement element) { - return readElements(element.findChildOrEmpty("w:sdtContent").getChildren()); - } - - private String relationshipIdToDocxPath(String relationshipId) { - Relationship relationship = relationships.findRelationshipById(relationshipId); - return uriToZipEntryName("word", relationship.getTarget()); - } - - private Optional<String> readVal(XmlElementLike element, String name) { - return element.findChildOrEmpty(name).getAttributeOrNone("w:val"); + public ReadResult readElement(XmlElement element) { + return new StatefulBodyXmlReader( + styles, + numbering, + relationships, + contentTypes, + file, + fileReader + ).readElement(element); } } diff --git a/src/main/java/org/zwobble/mammoth/internal/docx/StatefulBodyXmlReader.java b/src/main/java/org/zwobble/mammoth/internal/docx/StatefulBodyXmlReader.java new file mode 100644 index 0000000..f02a2e1 --- /dev/null +++ b/src/main/java/org/zwobble/mammoth/internal/docx/StatefulBodyXmlReader.java @@ -0,0 +1,539 @@ +package org.zwobble.mammoth.internal.docx; + +import org.zwobble.mammoth.internal.archives.Archive; +import org.zwobble.mammoth.internal.archives.Archives; +import org.zwobble.mammoth.internal.documents.*; +import org.zwobble.mammoth.internal.results.InternalResult; +import org.zwobble.mammoth.internal.util.Casts; +import org.zwobble.mammoth.internal.util.InputStreamSupplier; +import org.zwobble.mammoth.internal.util.Lists; +import org.zwobble.mammoth.internal.util.Optionals; +import org.zwobble.mammoth.internal.xml.XmlElement; +import org.zwobble.mammoth.internal.xml.XmlElementLike; +import org.zwobble.mammoth.internal.xml.XmlElementList; +import org.zwobble.mammoth.internal.xml.XmlNode; + +import java.util.*; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.zwobble.mammoth.internal.docx.ReadResult.EMPTY_SUCCESS; +import static org.zwobble.mammoth.internal.docx.ReadResult.success; +import static org.zwobble.mammoth.internal.docx.Uris.uriToZipEntryName; +import static org.zwobble.mammoth.internal.util.Iterables.lazyFilter; +import static org.zwobble.mammoth.internal.util.Iterables.tryGetLast; +import static org.zwobble.mammoth.internal.util.Lists.list; +import static org.zwobble.mammoth.internal.util.Maps.entry; +import static org.zwobble.mammoth.internal.util.Maps.lookup; +import static org.zwobble.mammoth.internal.util.Sets.set; + +class StatefulBodyXmlReader { + private static final Set<String> IMAGE_TYPES_SUPPORTED_BY_BROWSERS = set( + "image/png", "image/gif", "image/jpeg", "image/svg+xml", "image/tiff"); + + private final Styles styles; + private final Numbering numbering; + private final Relationships relationships; + private final ContentTypes contentTypes; + private final Archive file; + private final FileReader fileReader; + private final StringBuilder currentInstrText; + private final Queue<ComplexField> complexFields; + + private interface ComplexField { + ComplexField UNKNOWN = new ComplexField() {}; + + static ComplexField hyperlink(String href) { + return new HyperlinkComplexField(href); + } + } + + private static class HyperlinkComplexField implements ComplexField { + private final String href; + + private HyperlinkComplexField(String href) { + this.href = href; + } + } + + StatefulBodyXmlReader( + Styles styles, + Numbering numbering, + Relationships relationships, + ContentTypes contentTypes, + Archive file, + FileReader fileReader + ) + { + this.styles = styles; + this.numbering = numbering; + this.relationships = relationships; + this.contentTypes = contentTypes; + this.file = file; + this.fileReader = fileReader; + this.currentInstrText = new StringBuilder(); + this.complexFields = Collections.asLifoQueue(new ArrayDeque<>()); + } + + ReadResult readElement(XmlElement element) { + switch (element.getName()) { + case "w:t": + return success(new Text(element.innerText())); + case "w:r": + return readRun(element); + case "w:p": + return readParagraph(element); + + case "w:fldChar": + return readFieldChar(element); + case "w:instrText": + return readInstrText(element); + + case "w:tab": + return success(Tab.TAB); + case "w:br": + return readBreak(element); + + case "w:tbl": + return readTable(element); + case "w:tr": + return readElements(element.getChildren()).map(TableRow::new); + case "w:tc": + return readTableCell(element); + + case "w:hyperlink": + return readHyperlink(element); + case "w:bookmarkStart": + return readBookmark(element); + case "w:footnoteReference": + return readNoteReference(NoteType.FOOTNOTE, element); + case "w:endnoteReference": + return readNoteReference(NoteType.ENDNOTE, element); + case "w:commentReference": + return readCommentReference(element); + + case "w:pict": + return readPict(element); + + case "v:imagedata": + return readImagedata(element); + + case "wp:inline": + case "wp:anchor": + return readInline(element); + + case "w:sdt": + return readSdt(element); + + case "w:ins": + case "w:smartTag": + case "w:drawing": + case "v:roundrect": + case "v:shape": + case "v:textbox": + case "w:txbxContent": + return readElements(element.getChildren()); + + case "office-word:wrap": + case "v:shadow": + case "v:shapetype": + case "w:bookmarkEnd": + case "w:sectPr": + case "w:proofErr": + case "w:lastRenderedPageBreak": + case "w:commentRangeStart": + case "w:commentRangeEnd": + case "w:del": + case "w:footnoteRef": + case "w:endnoteRef": + case "w:annotationRef": + case "w:pPr": + case "w:rPr": + case "w:tblPr": + case "w:tblGrid": + case "w:tcPr": + return EMPTY_SUCCESS; + + default: + String warning = "An unrecognised element was ignored: " + element.getName(); + return ReadResult.emptyWithWarning(warning); + } + } + + private ReadResult readRun(XmlElement element) { + XmlElementLike properties = element.findChildOrEmpty("w:rPr"); + return ReadResult.map( + readRunStyle(properties), + readElements(element.getChildren()), + (style, children) -> { + Optional<String> hyperlinkHref = currentHyperlinkHref(); + if (hyperlinkHref.isPresent()) { + children = list(Hyperlink.href(hyperlinkHref.get(), children)); + } + + return new Run( + isBold(properties), + isItalic(properties), + isUnderline(properties), + isStrikethrough(properties), + readVerticalAlignment(properties), + style, + children + ); + } + ); + } + + private Optional<String> currentHyperlinkHref() { + return tryGetLast(lazyFilter(this.complexFields, HyperlinkComplexField.class)) + .map(field -> field.href); + } + + private boolean isBold(XmlElementLike properties) { + return readBooleanElement(properties, "w:b"); + } + + private boolean isItalic(XmlElementLike properties) { + return readBooleanElement(properties, "w:i"); + } + + private boolean isUnderline(XmlElementLike properties) { + return readBooleanElement(properties, "w:u"); + } + + private boolean isStrikethrough(XmlElementLike properties) { + return readBooleanElement(properties, "w:strike"); + } + + private boolean readBooleanElement(XmlElementLike properties, String tagName) { + return properties.findChild(tagName) + .map(child -> child.getAttributeOrNone("w:val") + .map(value -> !value.equals("false") && !value.equals("0")) + .orElse(true)) + .orElse(false); + } + + private VerticalAlignment readVerticalAlignment(XmlElementLike properties) { + String verticalAlignment = readVal(properties, "w:vertAlign").orElse(""); + switch (verticalAlignment) { + case "superscript": + return VerticalAlignment.SUPERSCRIPT; + case "subscript": + return VerticalAlignment.SUBSCRIPT; + default: + // TODO: warn if set? + return VerticalAlignment.BASELINE; + } + } + + private InternalResult<Optional<Style>> readRunStyle(XmlElementLike properties) { + return readStyle(properties, "w:rStyle", "Run", styles::findCharacterStyleById); + } + + ReadResult readElements(Iterable<XmlNode> nodes) { + return ReadResult.flatMap(lazyFilter(nodes, XmlElement.class), this::readElement); + } + + private ReadResult readParagraph(XmlElement element) { + XmlElementLike properties = element.findChildOrEmpty("w:pPr"); + Optional<NumberingLevel> numbering = readNumbering(properties); + return ReadResult.map( + readParagraphStyle(properties), + readElements(element.getChildren()), + (style, children) -> new Paragraph(style, numbering, children)).appendExtra(); + } + + private ReadResult readFieldChar(XmlElement element) { + String type = element.getAttributeOrNone("w:fldCharType").orElse(""); + if (type.equals("begin")) { + currentInstrText.setLength(0); + } else if (type.equals("end")) { + complexFields.remove(); + } else if (type.equals("separate")) { + String instrText = currentInstrText.toString(); + ComplexField complexField = parseHyperlinkFieldCode(instrText) + .map(href -> ComplexField.hyperlink(href)) + .orElse(ComplexField.UNKNOWN); + complexFields.add(complexField); + } + return ReadResult.EMPTY_SUCCESS; + } + + private ReadResult readInstrText(XmlElement element) { + currentInstrText.append(element.innerText()); + return ReadResult.EMPTY_SUCCESS; + } + + private Optional<String> parseHyperlinkFieldCode(String instrText) { + Pattern pattern = Pattern.compile("\\s*HYPERLINK \"(.*)\""); + Matcher matcher = pattern.matcher(instrText); + if (matcher.lookingAt()) { + return Optional.of(matcher.group(1)); + } else { + return Optional.empty(); + } + } + + private InternalResult<Optional<Style>> readParagraphStyle(XmlElementLike properties) { + return readStyle(properties, "w:pStyle", "Paragraph", styles::findParagraphStyleById); + } + + private InternalResult<Optional<Style>> readStyle( + XmlElementLike properties, + String styleTagName, + String styleType, + Function<String, Optional<Style>> findStyleById) + { + return readVal(properties, styleTagName) + .map(styleId -> findStyleById(styleType, styleId, findStyleById)) + .orElse(InternalResult.empty()); + } + + private InternalResult<Optional<Style>> findStyleById( + String styleType, + String styleId, + Function<String, Optional<Style>> findStyleById) + { + Optional<Style> style = findStyleById.apply(styleId); + if (style.isPresent()) { + return InternalResult.success(style); + } else { + return new InternalResult<>( + Optional.of(new Style(styleId, Optional.empty())), + list(styleType + " style with ID " + styleId + " was referenced but not defined in the document")); + } + + } + + private Optional<NumberingLevel> readNumbering(XmlElementLike properties) { + XmlElementLike numberingProperties = properties.findChildOrEmpty("w:numPr"); + return Optionals.flatMap( + readVal(numberingProperties, "w:numId"), + readVal(numberingProperties, "w:ilvl"), + numbering::findLevel); + } + + private ReadResult readBreak(XmlElement element) { + String breakType = element.getAttributeOrNone("w:type").orElse("textWrapping"); + switch (breakType) { + case "textWrapping": + return success(Break.LINE_BREAK); + case "page": + return success(Break.PAGE_BREAK); + case "column": + return success(Break.COLUMN_BREAK); + default: + return ReadResult.emptyWithWarning("Unsupported break type: " + breakType); + } + } + + private ReadResult readTable(XmlElement element) { + return readElements(element.getChildren()) + .flatMap(this::calculateRowspans) + .map(Table::new); + } + + private ReadResult calculateRowspans(List<DocumentElement> rows) { + Optional<String> error = checkTableRows(rows); + if (error.isPresent()) { + return ReadResult.withWarning(rows, error.get()); + } + + Map<Map.Entry<Integer, Integer>, Integer> rowspans = new HashMap<>(); + Set<Map.Entry<Integer, Integer>> merged = new HashSet<>(); + + Map<Integer, Map.Entry<Integer, Integer>> lastCellForColumn = new HashMap<>(); + for (int rowIndex = 0; rowIndex < rows.size(); rowIndex += 1) { + TableRow row = (TableRow) rows.get(rowIndex); + int columnIndex = 0; + for (int cellIndex = 0; cellIndex < row.getChildren().size(); cellIndex += 1) { + UnmergedTableCell cell = (UnmergedTableCell) row.getChildren().get(cellIndex); + Optional<Map.Entry<Integer, Integer>> spanningCell = lookup(lastCellForColumn, columnIndex); + Map.Entry<Integer, Integer> position = entry(rowIndex, cellIndex); + if (cell.vmerge && spanningCell.isPresent()) { + rowspans.put(spanningCell.get(), lookup(rowspans, spanningCell.get()).get() + 1); + merged.add(position); + } else { + lastCellForColumn.put(columnIndex, position); + rowspans.put(position, 1); + } + columnIndex += cell.colspan; + } + } + + return success(Lists.eagerMapWithIndex(rows, (rowIndex, rowElement) -> { + TableRow row = (TableRow) rowElement; + + List<DocumentElement> mergedCells = new ArrayList<>(); + for (int cellIndex = 0; cellIndex < row.getChildren().size(); cellIndex += 1) { + UnmergedTableCell cell = (UnmergedTableCell) row.getChildren().get(cellIndex); + Map.Entry<Integer, Integer> position = entry(rowIndex, cellIndex); + if (!merged.contains(position)) { + mergedCells.add(new TableCell( + lookup(rowspans, position).get(), + cell.colspan, + cell.children + )); + } + } + + return new TableRow(mergedCells); + })); + } + + private Optional<String> checkTableRows(List<DocumentElement> rows) { + for (DocumentElement rowElement : rows) { + Optional<TableRow> row = Casts.tryCast(TableRow.class, rowElement); + if (!row.isPresent()) { + return Optional.of("unexpected non-row element in table, cell merging may be incorrect"); + } else { + for (DocumentElement cell : row.get().getChildren()) { + if (!(cell instanceof UnmergedTableCell)) { + return Optional.of("unexpected non-cell element in table row, cell merging may be incorrect"); + } + } + } + } + return Optional.empty(); + } + + private ReadResult readTableCell(XmlElement element) { + XmlElementLike properties = element.findChildOrEmpty("w:tcPr"); + Optional<String> gridSpan = properties + .findChildOrEmpty("w:gridSpan") + .getAttributeOrNone("w:val"); + int colspan = gridSpan.map(Integer::parseInt).orElse(1); + return readElements(element.getChildren()) + .map(children -> new UnmergedTableCell(readVmerge(properties), colspan, children)); + } + + private boolean readVmerge(XmlElementLike properties) { + return properties.findChild("w:vMerge") + .map(element -> element.getAttributeOrNone("w:val").map(val -> val.equals("continue")).orElse(true)) + .orElse(false); + } + + private static class UnmergedTableCell implements DocumentElement { + private final boolean vmerge; + private final int colspan; + private final List<DocumentElement> children; + + private UnmergedTableCell(boolean vmerge, int colspan, List<DocumentElement> children) { + this.vmerge = vmerge; + this.colspan = colspan; + this.children = children; + } + + @Override + public <T> T accept(DocumentElementVisitor<T> visitor) { + return visitor.visit(new TableCell(1, colspan, children)); + } + } + + private ReadResult readHyperlink(XmlElement element) { + Optional<String> relationshipId = element.getAttributeOrNone("r:id"); + Optional<String> anchor = element.getAttributeOrNone("w:anchor"); + ReadResult childrenResult = readElements(element.getChildren()); + if (relationshipId.isPresent()) { + return childrenResult.map(children -> Hyperlink.href( + relationships.findRelationshipById(relationshipId.get()).getTarget(), children)); + } else if (anchor.isPresent()) { + return childrenResult.map(children -> Hyperlink.anchor( + anchor.get(), children)); + } else { + return childrenResult; + } + } + + private ReadResult readBookmark(XmlElement element) { + String name = element.getAttribute("w:name"); + if (name.equals("_GoBack")) { + return ReadResult.EMPTY_SUCCESS; + } else { + return success(new Bookmark(name)); + } + } + + private ReadResult readNoteReference(NoteType noteType, XmlElement element) { + String noteId = element.getAttribute("w:id"); + return success(new NoteReference(noteType, noteId)); + } + + private ReadResult readCommentReference(XmlElement element) { + String commentId = element.getAttribute("w:id"); + return success(new CommentReference(commentId)); + } + + private ReadResult readPict(XmlElement element) { + return readElements(element.getChildren()).toExtra(); + } + + private ReadResult readImagedata(XmlElement element) { + return element.getAttributeOrNone("r:id") + .map(relationshipId -> { + Optional<String> title = element.getAttributeOrNone("o:title"); + String imagePath = relationshipIdToDocxPath(relationshipId); + return readImage(imagePath, title, () -> Archives.getInputStream(file, imagePath)); + }) + .orElse(ReadResult.emptyWithWarning("A v:imagedata element without a relationship ID was ignored")); + } + + private ReadResult readInline(XmlElement element) { + XmlElementLike properties = element.findChildOrEmpty("wp:docPr"); + Optional<String> altText = Optionals.first( + properties.getAttributeOrNone("descr").filter(description -> !description.trim().isEmpty()), + properties.getAttributeOrNone("title") + ); + XmlElementList blips = element.findChildren("a:graphic") + .findChildren("a:graphicData") + .findChildren("pic:pic") + .findChildren("pic:blipFill") + .findChildren("a:blip"); + return readBlips(blips, altText); + } + + private ReadResult readBlips(XmlElementList blips, Optional<String> altText) { + return ReadResult.flatMap(blips, blip -> readBlip(blip, altText)); + } + + private ReadResult readBlip(XmlElement blip, Optional<String> altText) { + Optional<String> embedRelationshipId = blip.getAttributeOrNone("r:embed"); + Optional<String> linkRelationshipId = blip.getAttributeOrNone("r:link"); + if (embedRelationshipId.isPresent()) { + String imagePath = relationshipIdToDocxPath(embedRelationshipId.get()); + return readImage(imagePath, altText, () -> Archives.getInputStream(file, imagePath)); + } else if (linkRelationshipId.isPresent()) { + String imagePath = relationships.findRelationshipById(linkRelationshipId.get()).getTarget(); + return readImage(imagePath, altText, () -> fileReader.getInputStream(imagePath)); + } else { + // TODO: emit warning + return ReadResult.EMPTY_SUCCESS; + } + } + + private ReadResult readImage(String imagePath, Optional<String> altText, InputStreamSupplier open) { + Optional<String> contentType = contentTypes.findContentType(imagePath); + Image image = new Image(altText, contentType, open); + + String contentTypeString = contentType.orElse("(unknown)"); + if (IMAGE_TYPES_SUPPORTED_BY_BROWSERS.contains(contentTypeString)) { + return success(image); + } else { + return ReadResult.withWarning(image, "Image of type " + contentTypeString + " is unlikely to display in web browsers"); + } + } + + private ReadResult readSdt(XmlElement element) { + return readElements(element.findChildOrEmpty("w:sdtContent").getChildren()); + } + + private String relationshipIdToDocxPath(String relationshipId) { + Relationship relationship = relationships.findRelationshipById(relationshipId); + return uriToZipEntryName("word", relationship.getTarget()); + } + + private Optional<String> readVal(XmlElementLike element, String name) { + return element.findChildOrEmpty(name).getAttributeOrNone("w:val"); + } +} diff --git a/src/main/java/org/zwobble/mammoth/internal/util/Iterables.java b/src/main/java/org/zwobble/mammoth/internal/util/Iterables.java index 88e906e..85fdaee 100644 --- a/src/main/java/org/zwobble/mammoth/internal/util/Iterables.java +++ b/src/main/java/org/zwobble/mammoth/internal/util/Iterables.java @@ -54,6 +54,20 @@ public class Iterables { } } + public static <T> Optional<T> tryGetLast(Iterable<? extends T> iterable) { + Iterator<? extends T> iterator = iterable.iterator(); + if (!iterator.hasNext()) { + return Optional.empty(); + } + + while (true) { + T last = iterator.next(); + if (!iterator.hasNext()) { + return Optional.of(last); + } + } + } + public static <T, R> Iterable<R> lazyFlatMap(Iterable<T> iterable, Function<T, Iterable<R>> function) { return lazyFlatten(lazyMap(iterable, function)); } |
