This is an automated email from the ASF dual-hosted git repository.

tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/main by this push:
     new fe5ba58bc1 TIKA-4966: apply font formatting styles in docx (#3316)
fe5ba58bc1 is described below

commit fe5ba58bc1bee4f37217c32a05a9b74db7fa51ba
Author: Tim Allison <[email protected]>
AuthorDate: Sat Oct 17 13:27:09 2026 +0100

    TIKA-4966: apply font formatting styles in docx (#3316)
---
 CHANGES.txt                                        |   8 ++
 .../tika/parser/microsoft/OfficeParserConfig.java  |  18 +++
 .../tika/parser/microsoft/WordExtractor.java       |  25 +++-
 .../microsoft/ooxml/FormattingTagManager.java      |  35 +++++-
 .../microsoft/ooxml/OOXMLTikaBodyPartHandler.java  |  28 ++++-
 .../ooxml/OOXMLWordAndPowerPointTextHandler.java   |  12 +-
 .../tika/parser/microsoft/ooxml/RunProperties.java |  45 ++++++++
 .../microsoft/ooxml/xwpf/XWPFStylesShim.java       | 127 +++++++++++++++++----
 .../tika/parser/microsoft/WordParserTest.java      |  14 +++
 .../parser/microsoft/ooxml/OOXMLDocxSAXTest.java   |  37 ++++++
 .../test-documents/testWORD_characterStyles.docx   | Bin 0 -> 1944 bytes
 11 files changed, 314 insertions(+), 35 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index e2e97146b8..e81aa603bb 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,13 @@
 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 <span class="..."> named 
after
+     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).
+
    * OpenAI-compatible VLM parser can send the output token limit as
      max_completion_tokens for models that reject max_tokens. The new
      useMaxCompletionTokens setting is opt-in; max_tokens remains the default.
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 5b174caaaa..6e8708baff 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 class OfficeParserConfig implements Serializable {
         this.includeGlossary = includeGlossary;
     }
 
+    public boolean isIncludeCharacterStyleClasses() {
+        return includeCharacterStyleClasses;
+    }
+
+    /**
+     * Whether to wrap docx runs that carry a character style in
+     * <code>&lt;span class="..."&gt;</code>, named after the style the same 
way
+     * paragraph styles are.
+     * <p/>
+     * Default: <code>false</code>
+     *
+     * @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 346de85dcc..444e6100ef 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.List;
 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<String, TagAndStyle> 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 {
@@ -170,14 +173,30 @@ public class WordExtractor extends AbstractPOIFSExtractor 
{
             // 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" -&gt; 
"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) {
+        if (styleName == null || styleName.isEmpty()) {
+            return null;
+        }
+        // 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)
             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 3e8ae2f0de..8717b524aa 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 @@ class FormattingTagManager {
         if (url == null) {
             return;
         }
-        closeFormattingTags();
+        closeInlineTags();
         if (currentHyperlink != null) {
             xhtml.endElement("a");
         }
@@ -79,7 +80,7 @@ class FormattingTagManager {
      */
     void closeHyperlink() throws SAXException {
         if (currentHyperlink != null && wrapperHyperlinkActive) {
-            closeFormattingTags();
+            closeInlineTags();
             xhtml.endElement("a");
             currentHyperlink = null;
             wrapperHyperlinkActive = false;
@@ -98,11 +99,19 @@ class FormattingTagManager {
      * 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 <span class="spanClass">} 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 @@ class FormattingTagManager {
             }
         }
 
+        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 <b>. Nesting is <b><i><s><u>
             // (outermost to innermost), so close innermost first: u, s, i.
@@ -182,7 +199,7 @@ class FormattingTagManager {
      * 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 @@ class FormattingTagManager {
         }
     }
 
+    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 0366ab9670..7df2342fda 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 class OOXMLTikaBodyPartHandler
         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 class OOXMLTikaBodyPartHandler
         this.listManager = listManager;
         this.includeDeletedText = parserConfig.isIncludeDeletedContent();
         this.includeMoveFromText = parserConfig.isIncludeMoveFromContent();
+        this.includeCharacterStyleClasses = 
parserConfig.isIncludeCharacterStyleClasses();
     }
 
     /**
@@ -128,10 +132,32 @@ public class OOXMLTikaBodyPartHandler
 
     @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;
+        }
+        return WordExtractor.toStyleClass(styles.getStyleName(styleId));
+    }
+
     @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 e9aa3f001a..f1411245fd 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 @@ package org.apache.tika.parser.microsoft.ooxml;
 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 class OOXMLWordAndPowerPointTextHandler extends 
DefaultHandler {
         } 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 @@ public class OOXMLWordAndPowerPointTextHandler extends 
DefaultHandler {
         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 efed9c1348..56fbc6176e 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 <a:hlinkClick> inside <a:rPr>
     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 class RunProperties {
 
     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 class RunProperties {
     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 2698c058cc..c8f77dff47 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.parser.microsoft.ooxml.OOXMLWordAndPowerPointTextHandler;
 import org.apache.tika.utils.XMLReaderUtils;
 
 /**
- * For Tika, all we need (so far) is a mapping between styleId and a style's 
name.
- * <p>
- * 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,12 @@ public class XWPFStylesShim {
      */
     public static final XWPFStylesShim EMPTY_STYLES = new EmptyXWPFStyles();
 
-    private final Map<String, String> styles = new HashMap<>();
+    // 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<String, StyleInfo> styles = new HashMap<>();
 
     private XWPFStylesShim() {
 
@@ -72,7 +75,45 @@ public class XWPFStylesShim {
         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 +122,81 @@ public class 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 <w:style>; 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)) {
+                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)) {
+                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/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 27825f560d..8fa036da9b 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,20 @@ import org.apache.tika.sax.BodyContentHandler;
 
 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());
+        // 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));
+    }
+
     @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 92cb938058..08767ac8f5 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,43 @@ public class OOXMLDocxSAXTest extends TikaTest {
         assertContains("<a href=\"http://tika.apache.org/\";><b>hyper</b> 
link</a>; bold", xml);
     }
 
+    @Test
+    public void testCharacterStyleFormatting() throws Exception {
+        String xml = getXML("testWORD_characterStyles.docx").xml;
+        assertContains("<p>plain <b>strongStyle</b></p>", xml);
+        assertContains("<p><i>emphasisStyle</i></p>", xml);
+        assertContains("<p><b><i>inheritsEmphasis</i></b></p>", xml);
+        assertContains("<p>italicOff</p>", xml);
+        assertContains("<p>directOffStrong</p>", xml);
+        assertContains("<p><b>directBold</b></p>", xml);
+        assertContains("<p>cycle</p>", xml);
+        assertContains("<p>tableOnly</p>", xml);
+        assertContains("<p>unknownStyle</p>", xml);
+        // paragraph-level formatting is conveyed by the paragraph's class, 
not <b>
+        assertContains("<p class=\"bold_Para\">boldParaText</p>", xml);
+        assertNotContained("<span", xml);
+    }
+
+    @Test
+    public void testCharacterStyleClasses() throws Exception {
+        OfficeParserConfig config = new OfficeParserConfig();
+        config.setIncludeCharacterStyleClasses(true);
+        ParseContext parseContext = new ParseContext();
+        parseContext.set(OfficeParserConfig.class, config);
+        String xml = getXML("testWORD_characterStyles.docx", parseContext).xml;
+        assertContains("<p>plain <span 
class=\"strong\"><b>strongStyle</b></span></p>", xml);
+        assertContains("<span 
class=\"inherits_Emphasis\"><b><i>inheritsEmphasis</i></b></span>",
+                xml);
+        assertContains("<p><b>directBold</b></p>", xml);
+        assertContains("<p>unknownStyle</p>", xml);
+        // adjacent runs sharing a style share one span
+        assertContains("<span class=\"plain\">ab</span><span 
class=\"strong\"><b>c</b></span>",
+                xml);
+        // a tab in a style name must not split into a second, forged class 
token
+        assertContains("<p><span class=\"x_omml\">forged</span></p>", xml);
+        assertContains("<p><span class=\"l" + "L".repeat(127) + 
"\">longName</span></p>", 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 0000000000..f539eb08ef
Binary files /dev/null and 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testWORD_characterStyles.docx
 differ

Reply via email to