From 10ae9fbdc70d225888cd847fd4cdd51338d78ed4 Mon Sep 17 00:00:00 2001 From: tallison Date: Sat, 10 Oct 2026 09:01:41 -0400 Subject: [PATCH 1/3] TIKA-4966: apply font formatting styles in docx --- CHANGES.txt | 6 + .../parser/microsoft/OfficeParserConfig.java | 18 +++ .../tika/parser/microsoft/WordExtractor.java | 12 +- .../microsoft/ooxml/FormattingTagManager.java | 35 ++++- .../ooxml/OOXMLTikaBodyPartHandler.java | 30 ++++- .../OOXMLWordAndPowerPointTextHandler.java | 12 +- .../parser/microsoft/ooxml/RunProperties.java | 45 +++++++ .../microsoft/ooxml/xwpf/XWPFStylesShim.java | 123 +++++++++++++++--- .../microsoft/ooxml/OOXMLDocxSAXTest.java | 34 +++++ .../testWORD_characterStyles.docx | Bin 0 -> 1887 bytes 10 files changed, 280 insertions(+), 35 deletions(-) create mode 100644 tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testWORD_characterStyles.docx diff --git a/CHANGES.txt b/CHANGES.txt index 48dc600091e..d1b2a3ef41c 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,5 +1,11 @@ Release 4.2.0 - unreleased + * The docx parser now applies bold and italics set by a run's character style + (e.g. Word's built-in Strong and Emphasis), following basedOn chains; previously + only direct formatting was emitted. The new OfficeParserConfig option + includeCharacterStyleClasses wraps such runs in named after + the style (TIKA-4966). + * Config properties whose value reaches the host (paths, executables, class names, URLs, credentials, operator-sized limits) are marked @OperatorOnly and refused in per-request JSON before any setter runs. Newly locked: pdf-parser's diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/OfficeParserConfig.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/OfficeParserConfig.java index 5b174caaaa4..6e8708baffa 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/OfficeParserConfig.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/OfficeParserConfig.java @@ -46,6 +46,7 @@ public class OfficeParserConfig implements Serializable { private int rtfEmbeddedMaxBytesInKb = 2 * 1024 * 1024; // 2 GB private boolean includeGlossary = true; + private boolean includeCharacterStyleClasses = false; private String dateOverrideFormat = null; private int maxOverride = 0;//ignore @@ -203,6 +204,23 @@ public void setIncludeGlossary(boolean includeGlossary) { this.includeGlossary = includeGlossary; } + public boolean isIncludeCharacterStyleClasses() { + return includeCharacterStyleClasses; + } + + /** + * Whether to wrap docx runs that carry a character style in + * <span class="...">, named after the style the same way + * paragraph styles are. + *

+ * Default: false + * + * @param includeCharacterStyleClasses whether to emit character style classes + */ + public void setIncludeCharacterStyleClasses(boolean includeCharacterStyleClasses) { + this.includeCharacterStyleClasses = includeCharacterStyleClasses; + } + public boolean isIncludeMissingRows() { return includeMissingRows; } diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/WordExtractor.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/WordExtractor.java index 346de85dcc5..9af6233f229 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/WordExtractor.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/WordExtractor.java @@ -170,14 +170,20 @@ public static TagAndStyle buildParagraphTagAndStyle(String styleName, boolean is // Turn it into a H1 - H6 (H7+ isn't valid!) tag = "h" + Math.min(num, 6); } else { - styleClass = styleName.replace(' ', '_'); - styleClass = - styleClass.substring(0, 1).toLowerCase(Locale.ROOT) + styleClass.substring(1); + styleClass = toStyleClass(styleName); } return new TagAndStyle(tag, styleClass); } + /** + * @return the XHTML class for a style name, e.g. "List Bullet" -> "list_Bullet" + */ + public static String toStyleClass(String styleName) { + String styleClass = styleName.replace(' ', '_'); + return styleClass.substring(0, 1).toLowerCase(Locale.ROOT) + styleClass.substring(1); + } + protected void parse(POIFSFileSystem filesystem, XHTMLContentHandler xhtml) throws IOException, SAXException, TikaException { parse(filesystem.getRoot(), xhtml); diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/FormattingTagManager.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/FormattingTagManager.java index 3e8ae2f0de8..8717b524aa0 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/FormattingTagManager.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/FormattingTagManager.java @@ -44,8 +44,9 @@ class FormattingTagManager { private final XHTMLContentHandler xhtml; - // Outermost to innermost: hyperlink > bold > italic > strike > underline + // Outermost to innermost: hyperlink > span > bold > italic > strike > underline private String currentHyperlink = null; + private String currentSpanClass = null; private boolean wrapperHyperlinkActive = false; private boolean isBold = false; private boolean isItalics = false; @@ -65,7 +66,7 @@ void openHyperlink(String url) throws SAXException { if (url == null) { return; } - closeFormattingTags(); + closeInlineTags(); if (currentHyperlink != null) { xhtml.endElement("a"); } @@ -79,7 +80,7 @@ void openHyperlink(String url) throws SAXException { */ void closeHyperlink() throws SAXException { if (currentHyperlink != null && wrapperHyperlinkActive) { - closeFormattingTags(); + closeInlineTags(); xhtml.endElement("a"); currentHyperlink = null; wrapperHyperlinkActive = false; @@ -98,11 +99,19 @@ boolean isHyperlinkActive() { * opening and closing XHTML tags as needed to maintain proper nesting. */ void applyFormatting(RunProperties runProperties) throws SAXException { + applyFormatting(runProperties, null); + } + + /** + * As {@link #applyFormatting(RunProperties)}, additionally wrapping the run in + * {@code } when spanClass is non-null. + */ + void applyFormatting(RunProperties runProperties, String spanClass) throws SAXException { // Run-property hyperlinks only when no wrapper is active if (!wrapperHyperlinkActive) { String newHyperlink = runProperties.getHlinkClickUrl(); if (!Objects.equals(newHyperlink, currentHyperlink)) { - closeFormattingTags(); + closeInlineTags(); if (currentHyperlink != null) { xhtml.endElement("a"); } @@ -113,6 +122,14 @@ void applyFormatting(RunProperties runProperties) throws SAXException { } } + if (!Objects.equals(spanClass, currentSpanClass)) { + closeInlineTags(); + if (spanClass != null) { + xhtml.startElement("span", "class", spanClass); + } + currentSpanClass = spanClass; + } + if (runProperties.isBold() != isBold) { // Close inner tags before flipping . Nesting is // (outermost to innermost), so close innermost first: u, s, i. @@ -182,7 +199,7 @@ void applyFormatting(RunProperties runProperties) throws SAXException { * Closes all currently open tags in proper nesting order. */ void closeAll() throws SAXException { - closeFormattingTags(); + closeInlineTags(); if (currentHyperlink != null) { xhtml.endElement("a"); currentHyperlink = null; @@ -190,6 +207,14 @@ void closeAll() throws SAXException { } } + private void closeInlineTags() throws SAXException { + closeFormattingTags(); + if (currentSpanClass != null) { + xhtml.endElement("span"); + currentSpanClass = null; + } + } + private void closeFormattingTags() throws SAXException { if (isUnderline) { xhtml.endElement("u"); diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLTikaBodyPartHandler.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLTikaBodyPartHandler.java index 0366ab96700..df823c853d0 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLTikaBodyPartHandler.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLTikaBodyPartHandler.java @@ -49,6 +49,8 @@ public class OOXMLTikaBodyPartHandler private final XWPFListManager listManager; private final boolean includeDeletedText; private final boolean includeMoveFromText; + private final boolean includeCharacterStyleClasses; + private final RunProperties effectiveRunProperties = new RunProperties(); private final XWPFStylesShim styles; private final Metadata metadata; @@ -95,6 +97,7 @@ public OOXMLTikaBodyPartHandler(XHTMLContentHandler xhtml, Metadata metadata) { this.listManager = XWPFListManager.EMPTY_LIST; this.includeDeletedText = false; this.includeMoveFromText = false; + this.includeCharacterStyleClasses = false; } public OOXMLTikaBodyPartHandler(XHTMLContentHandler xhtml, XWPFStylesShim styles, @@ -113,6 +116,7 @@ public OOXMLTikaBodyPartHandler(XHTMLContentHandler xhtml, XWPFStylesShim styles this.listManager = listManager; this.includeDeletedText = parserConfig.isIncludeDeletedContent(); this.includeMoveFromText = parserConfig.isIncludeMoveFromContent(); + this.includeCharacterStyleClasses = parserConfig.isIncludeCharacterStyleClasses(); } /** @@ -128,10 +132,34 @@ public void setInlineBodyPartMap(OOXMLInlineBodyPartMap inlinePartMap, @Override public void run(RunProperties runProperties, String contents) throws SAXException { - formattingTags.applyFormatting(runProperties); + if (contents.isEmpty()) { + return; + } + String styleId = runProperties.getStyleID(); + if (styleId == null || styles == null) { + formattingTags.applyFormatting(runProperties); + } else { + effectiveRunProperties.copyFrom(runProperties); + if (!runProperties.isBoldSet()) { + effectiveRunProperties.bold = styles.isBold(styleId); + } + if (!runProperties.isItalicsSet()) { + effectiveRunProperties.italics = styles.isItalics(styleId); + } + formattingTags.applyFormatting(effectiveRunProperties, getSpanClass(styleId)); + } xhtml.characters(contents); } + private String getSpanClass(String styleId) { + if (!includeCharacterStyleClasses) { + return null; + } + String styleName = styles.getStyleName(styleId); + return styleName == null || styleName.isEmpty() ? null + : WordExtractor.toStyleClass(styleName); + } + @Override public void hyperlinkStart(String link) throws SAXException { formattingTags.openHyperlink(link); diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLWordAndPowerPointTextHandler.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLWordAndPowerPointTextHandler.java index e9aa3f001a8..f1411245fda 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLWordAndPowerPointTextHandler.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLWordAndPowerPointTextHandler.java @@ -20,7 +20,6 @@ import java.util.Date; import java.util.Map; -import org.apache.poi.xwpf.usermodel.UnderlinePatterns; import org.xml.sax.Attributes; import org.xml.sax.SAXException; import org.xml.sax.helpers.DefaultHandler; @@ -56,6 +55,7 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler { private final static String RPR = "rPr"; private final static String P = "p"; private final static String P_STYLE = "pStyle"; + private final static String R_STYLE = "rStyle"; private final static String PPR = "pPr"; private final static String T = "t"; private final static String TAB = "tab"; @@ -290,6 +290,10 @@ public void startElement(String uri, String localName, String qName, Attributes } else if (P_STYLE.equals(localName)) { String styleId = atts.getValue(W_NS, "val"); currPProperties.setStyleID(styleId); + } else if (R_STYLE.equals(localName)) { + if (inR && inRPr) { + currRunProperties.setStyleID(atts.getValue(W_NS, "val")); + } } else if (I.equals(localName)) { //TODO: add iCs //rprs don't have to be inR; ignore those that aren't if (inR && inRPr) { @@ -630,11 +634,7 @@ private void handleEndOfRun() throws SAXException { bodyContentsHandler.run(currRunProperties, runBuffer.toString()); inR = false; runBuffer.setLength(0); - currRunProperties.setBold(false); - currRunProperties.setItalics(false); - currRunProperties.setStrike(false); - currRunProperties.setUnderline(UnderlinePatterns.NONE.name()); - currRunProperties.setHlinkClickUrl(null); + currRunProperties.reset(); } @Override diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/RunProperties.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/RunProperties.java index efed9c13482..56fbc6176e5 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/RunProperties.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/RunProperties.java @@ -33,12 +33,22 @@ public class RunProperties { // PPTX hlinkClick hyperlink URL — set from inside String hlinkClickUrl = null; + // docx character style (w:rStyle); direct b/i, once set, override it + String styleID = null; + boolean boldSet = false; + boolean italicsSet = false; + public boolean isItalics() { return italics; } public void setItalics(boolean italics) { this.italics = italics; + this.italicsSet = true; + } + + public boolean isItalicsSet() { + return italicsSet; } public boolean isBold() { @@ -47,6 +57,19 @@ public boolean isBold() { public void setBold(boolean bold) { this.bold = bold; + this.boldSet = true; + } + + public boolean isBoldSet() { + return boldSet; + } + + public String getStyleID() { + return styleID; + } + + public void setStyleID(String styleID) { + this.styleID = styleID; } public boolean isStrikeThrough() { @@ -79,4 +102,26 @@ public String getHlinkClickUrl() { public void setHlinkClickUrl(String url) { this.hlinkClickUrl = url; } + + void copyFrom(RunProperties other) { + italics = other.italics; + bold = other.bold; + strikeThrough = other.strikeThrough; + underline = other.underline; + hlinkClickUrl = other.hlinkClickUrl; + styleID = other.styleID; + boldSet = other.boldSet; + italicsSet = other.italicsSet; + } + + public void reset() { + italics = false; + bold = false; + strikeThrough = false; + underline = UnderlinePatterns.NONE; + hlinkClickUrl = null; + styleID = null; + boldSet = false; + italicsSet = false; + } } diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xwpf/XWPFStylesShim.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xwpf/XWPFStylesShim.java index 2698c058ccd..54ecbb045ad 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xwpf/XWPFStylesShim.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xwpf/XWPFStylesShim.java @@ -32,10 +32,8 @@ import org.apache.tika.utils.XMLReaderUtils; /** - * For Tika, all we need (so far) is a mapping between styleId and a style's name. - *

- * This class uses SAX to scrape that info out of the styles.xml file. If - * either the styleId or the style's name is null, no information is recorded. + * Scrapes from styles.xml each style's name, its basedOn parent and whether + * its own run properties set bold or italics. */ public class XWPFStylesShim { @@ -44,7 +42,10 @@ public class XWPFStylesShim { */ public static final XWPFStylesShim EMPTY_STYLES = new EmptyXWPFStyles(); - private final Map styles = new HashMap<>(); + // bounds basedOn chains, including cyclic ones + private static final int MAX_BASED_ON_DEPTH = 32; + + private final Map styles = new HashMap<>(); private XWPFStylesShim() { @@ -72,7 +73,45 @@ public String getStyleName(String styleId) { if (styleId == null) { return null; } - return styles.get(styleId); + StyleInfo info = styles.get(styleId); + return info == null ? null : info.name; + } + + /** + * @return whether the style, or the nearest style it is based on that says + * either way, sets bold; false if none does + */ + public boolean isBold(String styleId) { + StyleInfo info = styles.get(styleId); + for (int i = 0; info != null && i < MAX_BASED_ON_DEPTH; i++) { + if (info.bold != null) { + return info.bold; + } + info = info.basedOn == null ? null : styles.get(info.basedOn); + } + return false; + } + + /** + * @return whether the style, or the nearest style it is based on that says + * either way, sets italics; false if none does + */ + public boolean isItalics(String styleId) { + StyleInfo info = styles.get(styleId); + for (int i = 0; info != null && i < MAX_BASED_ON_DEPTH; i++) { + if (info.italics != null) { + return info.italics; + } + info = info.basedOn == null ? null : styles.get(info.basedOn); + } + return false; + } + + private static class StyleInfo { + String name; + String basedOn; + Boolean bold; + Boolean italics; } private static class EmptyXWPFStyles extends XWPFStylesShim { @@ -81,35 +120,79 @@ private static class EmptyXWPFStyles extends XWPFStylesShim { public String getStyleName(String styleId) { return null; } + + @Override + public boolean isBold(String styleId) { + return false; + } + + @Override + public boolean isItalics(String styleId) { + return false; + } } private class StylesStripper extends DefaultHandler { - String currentStyleId = null; + StyleInfo current = null; + // depth below the current ; its own rPr is at 1, toggles at 2 + int depth = 0; + boolean inStyleRPr = false; @Override public void startElement(String uri, String localName, String qName, Attributes atts) throws SAXException { - if (uri == null || OOXMLWordAndPowerPointTextHandler.W_NS.equals(uri)) { - if ("style".equals(localName)) { - currentStyleId = - atts.getValue(OOXMLWordAndPowerPointTextHandler.W_NS, "styleId"); - } else if ("name".equals(localName)) { - String name = atts.getValue(OOXMLWordAndPowerPointTextHandler.W_NS, "val"); - if (currentStyleId != null && name != null) { - styles.put(currentStyleId, name); - } + if (current != null) { + depth++; + } + if (uri != null && !OOXMLWordAndPowerPointTextHandler.W_NS.equals(uri)) { + return; + } + if ("style".equals(localName)) { + String styleId = + atts.getValue(OOXMLWordAndPowerPointTextHandler.W_NS, "styleId"); + if (styleId != null) { + current = new StyleInfo(); + styles.put(styleId, current); + depth = 0; } + } else if (current == null) { + return; + } else if (depth == 1 && "name".equals(localName)) { + current.name = getVal(atts); + } else if (depth == 1 && "basedOn".equals(localName)) { + current.basedOn = getVal(atts); + } else if (depth == 1 && "rPr".equals(localName)) { + inStyleRPr = true; + } else if (depth == 2 && inStyleRPr && "b".equals(localName)) { + current.bold = getOnOff(atts); + } else if (depth == 2 && inStyleRPr && "i".equals(localName)) { + current.italics = getOnOff(atts); } } @Override public void endElement(String uri, String localName, String qName) throws SAXException { - if (uri == null || OOXMLWordAndPowerPointTextHandler.W_NS.equals(uri)) { - if ("style".equals(localName)) { - currentStyleId = null; - } + if (current == null) { + return; + } + if (depth == 0) { + current = null; + return; } + if (depth == 1) { + inStyleRPr = false; + } + depth--; + } + + private String getVal(Attributes atts) { + return atts.getValue(OOXMLWordAndPowerPointTextHandler.W_NS, "val"); + } + + private boolean getOnOff(Attributes atts) { + String v = getVal(atts); + return v == null || !("0".equals(v) || "false".equals(v) || "off".equals(v)); } } diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLDocxSAXTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLDocxSAXTest.java index 92cb9380587..4217bb33281 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLDocxSAXTest.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLDocxSAXTest.java @@ -152,6 +152,40 @@ public void testBoldHyperlink() throws Exception { assertContains("hyper link; bold", xml); } + @Test + public void testCharacterStyleFormatting() throws Exception { + String xml = getXML("testWORD_characterStyles.docx").xml; + assertContains("

plain strongStyle

", xml); + assertContains("

emphasisStyle

", xml); + assertContains("

inheritsEmphasis

", xml); + assertContains("

italicOff

", xml); + assertContains("

directOffStrong

", xml); + assertContains("

directBold

", xml); + assertContains("

cycle

", xml); + assertContains("

tableOnly

", xml); + assertContains("

unknownStyle

", xml); + // paragraph-level formatting is conveyed by the paragraph's class, not + assertContains("

boldParaText

", xml); + assertNotContained("plain strongStyle

", xml); + assertContains("inheritsEmphasis", + xml); + assertContains("

directBold

", xml); + assertContains("

unknownStyle

", xml); + // adjacent runs sharing a style share one span + assertContains("abc", + xml); + } + @Test public void testLongForIntExceptionInSummaryDetails() throws Exception { assertContains("bold", diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testWORD_characterStyles.docx b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testWORD_characterStyles.docx new file mode 100644 index 0000000000000000000000000000000000000000..e1242c3e2a9071af508e31e31d85a60e536d31bb GIT binary patch literal 1887 zcmWIWW@Zs#U|`^2@U`)Z{hC*?;TMpn%f!GS45Xu-^Ycnl^Gf1FDhpDJWA!R>bJkAW z?RVHfq^*43p-)k&d^{WrEo67}90-tmaKg@W%`Y|i?I!vCm#6TpIB+QA|Md6M@2=xN zx5-w`=%9C9tofXU;f~Vw9{=`Lu6t*(m?y58S59^Qgde+hXPF1PB&ojf{ABpNLZ4-& zplpDw(yv8d6fI9YoxvvPn49QOc-dsT6YKno&$Z6W?#Y(l*gp5{o0kFWrYmyl7Jl0x zkv{E-z+zLbqKilGz0RNV%4LQ3rxyz^x#zT!-tWcZWa@YS zW(hzEs-2B4g&TlDq{Ya(Juu>dedvY;L#r+n>(;zQK5BqR%mFHs4TYNA_+jCtq~eF4A1Q%<)9`1rPJm zy-PpdW%!pkDbBECZ&m}3_ksEx`#!fv2T!kdonAlZhnGmCOYh8scimmDu8tKI)5w2$ zy!#I)s>kMj-ekTG=pkvK$7FzXd45rfKE!t^`N^fZz~lf*2T-5x<~rmczMDcK_o=FQ0t9JIUK#OJ(`hC%;|$3l}YU zFt__nLr&Juufd<0Q2p7pf5oO8pdV$~7#IX${zUfS)+zVBfGPdh``WHa)ze?LdPG`> zi0mnBJ(zPFH`;_5Sv72QN_5IgD;8ob z3gx(?`lR-AQLVqyYY8zQt(F#(gp)>lqplP#{IQRzWTL-=^mR_nxt9%>PMaQJXdNtd zVgGHdV8iF~XZx<561kh zjkI$FxY<6Y+|r(w(6YIHQCI#l&xGSk9;|+A)?=}1`2xTH=kE6x7Rw#cZDD+8v;92B z;^z-f_Irs1M^$C*XlY*{R-rxj(2Wf-hqzLOTM8FfalP_g`+d)wZA}T)e;h&|>u-b#0$Jkl1;&xCa3Wuz^3X`W4eyM!~Vo3(%Ohckcd-DLhhlzCxM^D=Y8^7*Jy z^J>!O$DzQeVPj=r;D<#`aY;KtEwr^hNe-@qluKc3rXRPOz=WH!uf_1Z4B?^z0sD%hVTz~iY zV{NyDm8Vu*>2lxkv|)O8d!=wwt}6SJXQC~>E#BF$CkP~{uGctIa&~I+PBYQ1>r7ZP zx17vcEXiK*THAA8^Mlrq5RIC99kVWM+iHCE%7sH0b{$#a{#xhI#%Ym{IO29Nx~_ir zXWl7hhj|nDW*#Z&zaqM1y~qA%{Nmbo6*k|A*}3gnMktR@?eDx<-&g5ra!7ReJqS`= zw&L2VCvFiEp>q5Svdg_1)1^y|Z!EEnh}L4g{p6>?MD-QU|NE5sOb!G)?cM70>8irS z{KZ%27us~|AM%-eT=`Q)%)VXiHWU9X|MS$YK zMj#cJ=o--rAcRI1V6}v=I6^lGJ&PkudIK{FIm4rCM^Ad3haeJVfHy0!AYou& M1wsd)2RXq!0LP*C_y7O^ literal 0 HcmV?d00001 From ba0d476cf701debe42c41417a5ae686e488d507e Mon Sep 17 00:00:00 2001 From: tallison Date: Sat, 10 Oct 2026 09:18:22 -0400 Subject: [PATCH 2/3] trivial update --- CHANGES.txt | 4 +++- .../tika/parser/microsoft/WordExtractor.java | 14 ++++++++++++-- .../ooxml/OOXMLTikaBodyPartHandler.java | 4 +--- .../microsoft/ooxml/xwpf/XWPFStylesShim.java | 6 +++++- .../tika/parser/microsoft/WordParserTest.java | 11 +++++++++++ .../microsoft/ooxml/OOXMLDocxSAXTest.java | 3 +++ .../testWORD_characterStyles.docx | Bin 1887 -> 1944 bytes 7 files changed, 35 insertions(+), 7 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index d1b2a3ef41c..823415863cc 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -4,7 +4,9 @@ Release 4.2.0 - unreleased (e.g. Word's built-in Strong and Emphasis), following basedOn chains; previously only direct formatting was emitted. The new OfficeParserConfig option includeCharacterStyleClasses wraps such runs in named after - the style (TIKA-4966). + the style. Style-derived classes (paragraph and character) now replace every + character other than letters, digits, '_' and '-' with '_' and are capped at 128 + characters, so a style name can no longer yield extra class tokens (TIKA-4966). * Config properties whose value reaches the host (paths, executables, class names, URLs, credentials, operator-sized limits) are marked @OperatorOnly and refused in diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/WordExtractor.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/WordExtractor.java index 9af6233f229..e3a9bfbfd73 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/WordExtractor.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/WordExtractor.java @@ -29,6 +29,7 @@ import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.regex.Pattern; import org.apache.poi.hwpf.HWPFDocument; import org.apache.poi.hwpf.HWPFOldDocument; @@ -78,6 +79,8 @@ public class WordExtractor extends AbstractPOIFSExtractor { private static final String LIST_DELIMITER = " "; private static final Map fixedParagraphStyles = new HashMap<>(); private static final TagAndStyle defaultParagraphStyle = new TagAndStyle("p", null); + private static final int MAX_STYLE_CLASS_LENGTH = 128; + private static final Pattern NON_STYLE_CLASS_CHARS = Pattern.compile("[^\\p{L}\\p{N}_-]"); private static final Logger LOG = LoggerFactory.getLogger(WordExtractor.class); static { @@ -177,10 +180,17 @@ public static TagAndStyle buildParagraphTagAndStyle(String styleName, boolean is } /** - * @return the XHTML class for a style name, e.g. "List Bullet" -> "list_Bullet" + * @return the XHTML class for a style name, e.g. "List Bullet" -> "list_Bullet", + * or null for an empty name. Anything but letters, digits, '_' and '-' becomes '_', + * so a style name can never yield more than one class token. */ public static String toStyleClass(String styleName) { - String styleClass = styleName.replace(' ', '_'); + if (styleName == null || styleName.isEmpty()) { + return null; + } + String styleClass = styleName.length() > MAX_STYLE_CLASS_LENGTH + ? styleName.substring(0, MAX_STYLE_CLASS_LENGTH) : styleName; + styleClass = NON_STYLE_CLASS_CHARS.matcher(styleClass).replaceAll("_"); return styleClass.substring(0, 1).toLowerCase(Locale.ROOT) + styleClass.substring(1); } diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLTikaBodyPartHandler.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLTikaBodyPartHandler.java index df823c853d0..7df2342fda7 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLTikaBodyPartHandler.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLTikaBodyPartHandler.java @@ -155,9 +155,7 @@ private String getSpanClass(String styleId) { if (!includeCharacterStyleClasses) { return null; } - String styleName = styles.getStyleName(styleId); - return styleName == null || styleName.isEmpty() ? null - : WordExtractor.toStyleClass(styleName); + return WordExtractor.toStyleClass(styles.getStyleName(styleId)); } @Override diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xwpf/XWPFStylesShim.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xwpf/XWPFStylesShim.java index 54ecbb045ad..c8f77dff477 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xwpf/XWPFStylesShim.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xwpf/XWPFStylesShim.java @@ -44,6 +44,8 @@ public class XWPFStylesShim { // bounds basedOn chains, including cyclic ones private static final int MAX_BASED_ON_DEPTH = 32; + // Word itself caps style names at 253 characters + private static final int MAX_STYLE_NAME_LENGTH = 255; private final Map styles = new HashMap<>(); @@ -159,7 +161,9 @@ public void startElement(String uri, String localName, String qName, Attributes } else if (current == null) { return; } else if (depth == 1 && "name".equals(localName)) { - current.name = getVal(atts); + String name = getVal(atts); + current.name = name != null && name.length() > MAX_STYLE_NAME_LENGTH + ? name.substring(0, MAX_STYLE_NAME_LENGTH) : name; } else if (depth == 1 && "basedOn".equals(localName)) { current.basedOn = getVal(atts); } else if (depth == 1 && "rPr".equals(localName)) { diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/WordParserTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/WordParserTest.java index 27825f560df..595e16b513d 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/WordParserTest.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/WordParserTest.java @@ -51,6 +51,17 @@ public class WordParserTest extends TikaTest { + @Test + public void testToStyleClass() { + assertEquals("list_Bullet", WordExtractor.toStyleClass("List Bullet")); + assertEquals("x_omml", WordExtractor.toStyleClass("x\tomml")); + assertEquals("table_Text___3__", WordExtractor.toStyleClass("Table Text (.3\")")); + assertEquals("überschrift_1", WordExtractor.toStyleClass("Überschrift 1")); + assertEquals(128, WordExtractor.toStyleClass("a".repeat(10_000)).length()); + assertNull(WordExtractor.toStyleClass("")); + assertNull(WordExtractor.toStyleClass(null)); + } + @Test public void testWordParser() throws Exception { try (TikaInputStream tis = getResourceAsStream("/test-documents/testWORD.doc")) { diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLDocxSAXTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLDocxSAXTest.java index 4217bb33281..08767ac8f52 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLDocxSAXTest.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLDocxSAXTest.java @@ -184,6 +184,9 @@ public void testCharacterStyleClasses() throws Exception { // adjacent runs sharing a style share one span assertContains("abc", xml); + // a tab in a style name must not split into a second, forged class token + assertContains("

forged

", xml); + assertContains("

longName

", xml); } @Test diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testWORD_characterStyles.docx b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testWORD_characterStyles.docx index e1242c3e2a9071af508e31e31d85a60e536d31bb..f539eb08ef3379ed98ad1aef094b98246a40f734 100644 GIT binary patch delta 963 zcmcc5H-n!iz?+#xgn@y9gJFT^MjlN@rUjmp%^9zQC;_Ht^$R?`VgppHEm{~E7@n~) zFbD#bmgg6x=%?f-m*%GCmFQLE=4_oB>wVZjr0xA*QPuBySGgx{DqCS8kfNRQuffFj z?*S7|&u#1e-}`cU;fWr`e4$zUp4V1Q`ZDL~t1BBGaClvBI{qR}a1n!rXyw_{5_{iX zzy4k0LfeNq1_u@0Wa~4wc&(e3JB7O{qha;|s{+}rhO2re3Lcvkc0pjTfY6TGaE_}2 z$?~C{(F==KvaqhzU6A<0jOTrUb5@^F_RP*buhVbxuaal`?&0mE8?AL=L0^N=$sCUj zN5Uq2S^SG9;HAY|pW_M{iY_f9@9Ma{X2( z>*O1=za7#J*m|*k(Y5~2_X4w|@nJYli^P_7sC@ATT~i`)M+ zy(+g+?edx9Sa(g*e1_s5v_kaqCe~kpktd_O|8#NzvTs@yJaF;QmGgw*j)Gwi#otMwdS$38+e9r7c5y_R$qB1or zt{Iu0ZqD2nF;Uqy#JhxHxz;6j)904cTvq!%{l{4Fu5ZGjlau0kuVwQ;ld2CC(!acA z?g^oQ#j_Vruq{oTA22`E=i`>CC-jO04iu^18YX09*zM6SU z{Nj#Bs>?&Y6FE1Ws48&@FAde;*9}&h`cF*t&A)Gbp?^1@J9Dx%+HyPp_xJKmhE_+) zboAyLb^kB?yyKtHx9wSfxC6WynM9aH*r8c$vN)SPC~rlunS-c_Y#zKQS#0t%HY0hE c2qO9z82EvjVW5F={$z7@B{nBuW@KOh0L){m<^TWy delta 922 zcmbQif1i&hz?+#xgn@y9gTdEkBabE{ldsKWbH?i+N`UEEy|0Z|Y}fu3n{pT#7-ZQP z7zBYz%kzs;^i%SaOLJ56O7tpnbGA;o?{(Nf5O?TD$&=soBw@2<3tZ@zF)?#+WYI(dDA!Agc(^iY;;O=%1bL2Vk`>fxTE@{_H$9K zztU?7F(0j#7L$bflSX@^t`si(v5%=_qQ8UmbxzH>mkpOrn;u|j9V~TW|81>c!{_p6 z`>vf5xuwB(t;#|5<=dJ%cWt!^OYDroT`#ZOrMpZwwkpT;@w~OX5eDoEE2rHP(p@3G zB-b>aZSvRCw@if=S=kxPoUqlHtGwmgtv5lN-Y%K@TW20${8IhN^R(+@jkI$FxY<6Y z+|r(w(6YIHQCI#l&xGSk9;|+A)?=}1`2xTH=kE6x7Rw#cZDD+8v;92B;^z-f_Irs1 zM^$C*XlY*{R-rxj(2Wf-hqzLOTM8FfalP_g`+d)wZA}T)e;h&|>u-b#0$ zJkl1;&xCa3Wuz^3X`W4eyM!~VyPmaw@rN^i?A>JkKa_c4QS&l$!}9saQRWr Date: Sat, 17 Oct 2026 07:03:07 -0400 Subject: [PATCH 3/3] copilot feedback about ordering of sanitization and lc --- .../apache/tika/parser/microsoft/WordExtractor.java | 11 +++++++---- .../apache/tika/parser/microsoft/WordParserTest.java | 3 +++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/WordExtractor.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/WordExtractor.java index e3a9bfbfd73..444e6100ef8 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/WordExtractor.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/WordExtractor.java @@ -188,10 +188,13 @@ public static String toStyleClass(String styleName) { if (styleName == null || styleName.isEmpty()) { return null; } - String styleClass = styleName.length() > MAX_STYLE_CLASS_LENGTH - ? styleName.substring(0, MAX_STYLE_CLASS_LENGTH) : styleName; - styleClass = NON_STYLE_CLASS_CHARS.matcher(styleClass).replaceAll("_"); - return styleClass.substring(0, 1).toLowerCase(Locale.ROOT) + styleClass.substring(1); + // sanitize last: lowercasing can expand a letter into a disallowed combining mark + String styleClass = + styleName.substring(0, 1).toLowerCase(Locale.ROOT) + styleName.substring(1); + if (styleClass.length() > MAX_STYLE_CLASS_LENGTH) { + styleClass = styleClass.substring(0, MAX_STYLE_CLASS_LENGTH); + } + return NON_STYLE_CLASS_CHARS.matcher(styleClass).replaceAll("_"); } protected void parse(POIFSFileSystem filesystem, XHTMLContentHandler xhtml) diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/WordParserTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/WordParserTest.java index 595e16b513d..8fa036da9b6 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/WordParserTest.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/WordParserTest.java @@ -58,6 +58,9 @@ public void testToStyleClass() { assertEquals("table_Text___3__", WordExtractor.toStyleClass("Table Text (.3\")")); assertEquals("überschrift_1", WordExtractor.toStyleClass("Überschrift 1")); assertEquals(128, WordExtractor.toStyleClass("a".repeat(10_000)).length()); + // lowercasing U+0130 yields "i" + U+0307; sanitizing must still have the last word + assertEquals("i_stanbul", WordExtractor.toStyleClass("İstanbul")); + assertEquals(128, WordExtractor.toStyleClass("İ".repeat(200)).length()); assertNull(WordExtractor.toStyleClass("")); assertNull(WordExtractor.toStyleClass(null)); }