Skip to content

Commit 6060d9a

Browse files
authored
TIKA-4696 improve inline tagging (#2711)
1 parent 9d732f4 commit 6060d9a

5 files changed

Lines changed: 867 additions & 14 deletions

File tree

tika-core/src/main/java/org/apache/tika/metadata/MAPI.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,18 @@ public interface MAPI {
7777
Property ATTACH_MIME = Property.internalText(PREFIX_MAPI_ATTACH_META + "mime");
7878
Property ATTACH_LANGUAGE = Property.internalText(PREFIX_MAPI_ATTACH_META + "language");
7979

80+
/**
81+
* PidTagAttachFlags (0x3714) — indicates which body formats might reference this attachment.
82+
* Bit 1 (0x1) = ATT_INVISIBLE_IN_HTML
83+
* Bit 2 (0x2) = ATT_INVISIBLE_IN_RTF
84+
* Bit 3 (0x4) = ATT_RENDERED_IN_BODY
85+
*/
86+
Property ATTACH_FLAGS = Property.internalInteger(PREFIX_MAPI_ATTACH_META + "flags");
87+
88+
/**
89+
* PidTagAttachmentHidden (0x7FFE) — indicates whether this attachment is hidden from the end
90+
* user. Inline images typically have this set to true.
91+
*/
92+
Property ATTACH_HIDDEN = Property.internalBoolean(PREFIX_MAPI_ATTACH_META + "hidden");
93+
8094
}

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/OutlookExtractor.java

Lines changed: 140 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,11 @@
2020

2121
import java.io.BufferedReader;
2222
import java.io.IOException;
23+
import java.io.InputStream;
2324
import java.io.InputStreamReader;
2425
import java.io.UnsupportedEncodingException;
26+
import java.nio.ByteBuffer;
27+
import java.nio.ByteOrder;
2528
import java.nio.charset.Charset;
2629
import java.nio.charset.IllegalCharsetNameException;
2730
import java.nio.charset.UnsupportedCharsetException;
@@ -56,7 +59,10 @@
5659
import org.apache.poi.hsmf.datatypes.StringChunk;
5760
import org.apache.poi.hsmf.datatypes.Types;
5861
import org.apache.poi.hsmf.exceptions.ChunkNotFoundException;
62+
import org.apache.poi.poifs.filesystem.DirectoryEntry;
5963
import org.apache.poi.poifs.filesystem.DirectoryNode;
64+
import org.apache.poi.poifs.filesystem.DocumentEntry;
65+
import org.apache.poi.poifs.filesystem.DocumentInputStream;
6066
import org.apache.poi.util.CodePageUtil;
6167
import org.slf4j.Logger;
6268
import org.slf4j.LoggerFactory;
@@ -79,6 +85,7 @@
7985
import org.apache.tika.parser.html.JSoupParser;
8086
import org.apache.tika.parser.mailcommons.MailDateParser;
8187
import org.apache.tika.parser.microsoft.msg.ExtendedMetadataExtractor;
88+
import org.apache.tika.parser.microsoft.msg.RTFEncapsulatedHTMLExtractor;
8289
import org.apache.tika.parser.microsoft.rtf.RTFParser;
8390
import org.apache.tika.parser.txt.CharsetDetector;
8491
import org.apache.tika.parser.txt.CharsetMatch;
@@ -173,6 +180,7 @@ private static void loadMessageClasses() {
173180
private static Pattern HEADER_KEY_PAT =
174181
Pattern.compile("\\A([\\x21-\\x39\\x3B-\\x7E]+):(.*?)\\Z");
175182

183+
private final DirectoryNode root;
176184
private final MAPIMessage msg;
177185
private final ParseContext parseContext;
178186
private final boolean extractAllAlternatives;
@@ -181,6 +189,7 @@ private static void loadMessageClasses() {
181189

182190
public OutlookExtractor(DirectoryNode root, Metadata metadata, ParseContext context) throws TikaException {
183191
super(context, metadata);
192+
this.root = root;
184193
this.parseContext = context;
185194
this.extractAllAlternatives =
186195
context.get(OfficeParserConfig.class).isExtractAllAlternativesFromMSG();
@@ -317,18 +326,7 @@ private void _parse(XHTMLContentHandler xhtml) throws TikaException, SAXExceptio
317326

318327
private void updateAttachmentMetadata(AttachmentChunks attachment, Metadata metadata,
319328
Set<String> contentIdNames) {
320-
StringChunk contentIdChunk = attachment.getAttachContentId();
321-
if (contentIdChunk != null) {
322-
String contentId = contentIdChunk.getValue();
323-
if (! StringUtils.isBlank(contentId)) {
324-
contentId = contentId.trim();
325-
if (contentIdNames.contains(contentId)) {
326-
metadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE_KEY,
327-
TikaCoreProperties.EmbeddedResourceType.INLINE.name());
328-
}
329-
metadata.set(MAPI.ATTACH_CONTENT_ID, contentId);
330-
}
331-
}
329+
// Extract string-based metadata from POI's named chunk getters
332330
addStringChunkToMetadata(MAPI.ATTACH_LONG_PATH_NAME, attachment.getAttachLongPathName(), metadata);
333331
addStringChunkToMetadata(MAPI.ATTACH_LONG_FILE_NAME, attachment.getAttachLongFileName(), metadata);
334332
addStringChunkToMetadata(MAPI.ATTACH_FILE_NAME, attachment.getAttachFileName(), metadata);
@@ -337,6 +335,129 @@ private void updateAttachmentMetadata(AttachmentChunks attachment, Metadata meta
337335
addStringChunkToMetadata(MAPI.ATTACH_EXTENSION, attachment.getAttachExtension(), metadata);
338336
addStringChunkToMetadata(MAPI.ATTACH_MIME, attachment.getAttachMimeTag(), metadata);
339337
addStringChunkToMetadata(MAPI.ATTACH_LANGUAGE, attachment.getAttachLanguage(), metadata);
338+
339+
// Extract fixed properties from the attachment's __properties_version1.0 stream
340+
// POI's AttachmentChunks doesn't parse this stream, so we read it directly.
341+
Map<Integer, Long> attachProps = readAttachmentProperties(attachment.getPOIFSName());
342+
Long attachFlags = attachProps.get(PID_TAG_ATTACH_FLAGS);
343+
if (attachFlags != null) {
344+
metadata.set(MAPI.ATTACH_FLAGS, attachFlags.intValue());
345+
}
346+
Long attachHidden = attachProps.get(PID_TAG_ATTACHMENT_HIDDEN);
347+
if (attachHidden != null) {
348+
metadata.set(MAPI.ATTACH_HIDDEN, attachHidden.intValue() != 0);
349+
}
350+
351+
// Determine inline vs attachment
352+
String contentId = null;
353+
StringChunk contentIdChunk = attachment.getAttachContentId();
354+
if (contentIdChunk != null) {
355+
String rawCid = contentIdChunk.getValue();
356+
if (!StringUtils.isBlank(rawCid)) {
357+
contentId = rawCid.trim();
358+
metadata.set(MAPI.ATTACH_CONTENT_ID, contentId);
359+
}
360+
}
361+
362+
if (contentId != null && contentIdNames.contains(contentId)) {
363+
// Layer 1: CID referenced in the message body — high confidence inline
364+
metadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE_KEY,
365+
TikaCoreProperties.EmbeddedResourceType.INLINE.name());
366+
} else if (contentId != null
367+
&& attachFlags != null
368+
&& (attachFlags & ATT_RENDERED_IN_BODY) != 0
369+
&& isInlineableMimeType(metadata.get(MAPI.ATTACH_MIME))) {
370+
// Layer 2: MAPI says rendered in body + image MIME type — the CID regex
371+
// missed it (e.g. encapsulated RTF with stripped img tags)
372+
metadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE_KEY,
373+
TikaCoreProperties.EmbeddedResourceType.INLINE.name());
374+
}
375+
}
376+
377+
private static final Set<String> INLINEABLE_MIME_TYPES = Set.of(
378+
"application/x-ms-wmz",
379+
"application/x-ms-emz",
380+
"application/x-msmetafile",
381+
"image/x-wmf",
382+
"image/x-emf",
383+
"image/wmf",
384+
"image/emf"
385+
);
386+
387+
/**
388+
* Returns true for MIME types that are safe to label as INLINE.
389+
* We gate on this to avoid marking PDFs, DOCX, etc. as inline — downstream
390+
* consumers use INLINE to decide what to index separately.
391+
*/
392+
private static boolean isInlineableMimeType(String mimeType) {
393+
if (StringUtils.isBlank(mimeType)) {
394+
return false;
395+
}
396+
String lower = mimeType.toLowerCase(Locale.ROOT).trim();
397+
return lower.startsWith("image/") || INLINEABLE_MIME_TYPES.contains(lower);
398+
}
399+
400+
// PidTagAttachFlags (0x3714) — bit flags indicating which body formats reference this
401+
private static final int PID_TAG_ATTACH_FLAGS = 0x3714;
402+
// Bit 2 = ATT_RENDERED_IN_BODY: this attachment is referenced by the body
403+
private static final int ATT_RENDERED_IN_BODY = 0x4;
404+
// PidTagAttachmentHidden (0x7FFE) — boolean, true if hidden from end user (inline images)
405+
private static final int PID_TAG_ATTACHMENT_HIDDEN = 0x7FFE;
406+
407+
/**
408+
* Read fixed MAPI properties from the __properties_version1.0 stream inside an
409+
* attachment storage. POI's {@link AttachmentChunks} does not parse this stream.
410+
*
411+
* <p>The stream format is: 8-byte header, followed by 16-byte property entries.
412+
* Each entry: 2 bytes property type, 2 bytes property ID, 4 bytes flags,
413+
* 8 bytes value (inline for fixed-size types).</p>
414+
*
415+
* @param poifsName the OLE2 directory name for this attachment
416+
* (e.g. "__attach_version1.0_#00000000")
417+
* @return map of property ID to value for fixed-size integer/boolean properties
418+
*/
419+
private Map<Integer, Long> readAttachmentProperties(String poifsName) {
420+
Map<Integer, Long> result = new HashMap<>();
421+
try {
422+
DirectoryEntry attachDir = (DirectoryEntry) root.getEntry(poifsName);
423+
DocumentEntry propsEntry =
424+
(DocumentEntry) attachDir.getEntry("__properties_version1.0");
425+
byte[] data;
426+
try (InputStream dis = new DocumentInputStream(propsEntry)) {
427+
data = dis.readAllBytes();
428+
}
429+
if (data.length < 8) {
430+
return result;
431+
}
432+
ByteBuffer buf = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN);
433+
int offset = 8; // skip 8-byte header
434+
while (offset + 16 <= data.length) {
435+
int propType = buf.getShort(offset) & 0xFFFF;
436+
int propId = buf.getShort(offset + 2) & 0xFFFF;
437+
long value;
438+
switch (propType) {
439+
case 0x0003: // PtypInteger32
440+
value = buf.getInt(offset + 8) & 0xFFFFFFFFL;
441+
result.put(propId, value);
442+
break;
443+
case 0x000B: // PtypBoolean
444+
value = buf.getShort(offset + 8) & 0xFFFF;
445+
result.put(propId, value);
446+
break;
447+
case 0x0014: // PtypInteger64
448+
value = buf.getLong(offset + 8);
449+
result.put(propId, value);
450+
break;
451+
default:
452+
// skip variable-length, binary, time and other types
453+
break;
454+
}
455+
offset += 16;
456+
}
457+
} catch (Exception e) {
458+
LOGGER.debug("Could not read attachment properties for {}", poifsName, e);
459+
}
460+
return result;
340461
}
341462

342463
private void addStringChunkToMetadata(Property property, StringChunk stringChunk, Metadata metadata) {
@@ -534,8 +655,13 @@ private void _handleBestBodyChunk(Chunk htmlChunk, Chunk rtfChunk, Chunk textChu
534655
}
535656

536657
private void extractContentIdNamesFromRtf(byte[] data, Metadata metadata, Set<String> contentIdNames) {
537-
//for now, hope that there's encapsulated html
538-
//TODO: check for encapsulated html. If it doesn't exist, handle RTF specifically
658+
// Try to de-encapsulate the HTML from the RTF first
659+
String html = RTFEncapsulatedHTMLExtractor.extract(data);
660+
if (html != null) {
661+
extractContentIdNamesFromHtml(html.getBytes(UTF_8), metadata, contentIdNames);
662+
return;
663+
}
664+
// Fall back to scanning the raw RTF bytes for cid: references
539665
extractContentIdNamesFromHtml(data, metadata, contentIdNames);
540666
}
541667

0 commit comments

Comments
 (0)