mirror of https://gitee.com/godoos/godoos.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
358 lines
10 KiB
358 lines
10 KiB
package office
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/richardlehane/mscfb"
|
|
"golang.org/x/text/encoding"
|
|
"golang.org/x/text/encoding/unicode"
|
|
"golang.org/x/text/transform"
|
|
)
|
|
|
|
// skipped metadata or non-readable records in slide container
|
|
var slideSkippedRecordsTypes = []recordType{
|
|
recordTypeExternalObjectList,
|
|
recordTypeEnvironment,
|
|
recordTypeSoundCollection,
|
|
recordTypeDrawingGroup,
|
|
recordTypeSlideListWithText,
|
|
recordTypeList,
|
|
recordTypeHeadersFooters,
|
|
recordTypeHeadersFooters,
|
|
}
|
|
|
|
// skipped metadata or non-readable records in drawing container
|
|
var drawingSkippedRecordsTypes = []recordType{
|
|
recordTypeSlideShowSlideInfoAtom,
|
|
recordTypeHeadersFooters,
|
|
recordTypeRoundTripSlideSyncInfo12,
|
|
}
|
|
|
|
const (
|
|
userPersistIDRefOffset = 16
|
|
)
|
|
|
|
// ExtractText parses PPT file represented by Reader r and extracts text from it.
|
|
func ExtractPPTText(r io.Reader) (string, error) {
|
|
ra := ToReaderAt(r)
|
|
|
|
d, err := mscfb.New(ra)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
currentUser, pptDocument := getCurrentUserAndPPTDoc(d)
|
|
if err := isValidPPT(currentUser, pptDocument); err != nil {
|
|
return "", err
|
|
}
|
|
offsetPersistDirectory, liveRecord, err := getUserEditAtomsData(currentUser, pptDocument)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
persistDirEntries, err := getPersistDirectoryEntries(pptDocument, offsetPersistDirectory)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// get DocumentContainer reference
|
|
docPersistIDRef := liveRecord.LongAt(userPersistIDRefOffset)
|
|
documentContainer, err := readRecord(pptDocument, persistDirEntries[docPersistIDRef], recordTypeDocument)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return readSlides(documentContainer, pptDocument, persistDirEntries)
|
|
}
|
|
|
|
// toMemoryBuffer transforms io.Reader to in-memory io.ReaderAt
|
|
|
|
// getCurrentUserAndPPTDoc extracts necessary mscfb files from PPT file
|
|
func getCurrentUserAndPPTDoc(r *mscfb.Reader) (currentUser *mscfb.File, pptDocument *mscfb.File) {
|
|
for _, f := range r.File {
|
|
switch f.Name {
|
|
case "Current User":
|
|
currentUser = f
|
|
case "PowerPoint Document":
|
|
pptDocument = f
|
|
}
|
|
}
|
|
return currentUser, pptDocument
|
|
}
|
|
|
|
// isValidPPT checks if provided file is valid, meaning
|
|
// it has both "Current User" and "PowerPoint Document" files
|
|
// and "Current User"'s CurrentUserAtom record has valid header token
|
|
func isValidPPT(currentUser, pptDocument *mscfb.File) error {
|
|
const (
|
|
headerTokenOffset = 12
|
|
encryptedDocumentToken = 0xF3D1C4DF
|
|
plainDocumentToken = 0xE391C05F
|
|
)
|
|
|
|
if currentUser == nil || pptDocument == nil {
|
|
return fmt.Errorf(".ppt file must contain \"Current User\" and \"PowerPoint Document\" streams")
|
|
}
|
|
var b [4]byte
|
|
_, err := currentUser.ReadAt(b[:], headerTokenOffset)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
headerToken := binary.LittleEndian.Uint32(b[:])
|
|
if headerToken != plainDocumentToken && headerToken != encryptedDocumentToken {
|
|
return fmt.Errorf("invalid UserEditAtom header token %X", headerToken)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// getUserEditAtomsData extracts "live record" and persist directory offsets
|
|
// according to section 2.1.2 of specification (https://msopenspecs.azureedge.net/files/MS-PPT/%5bMS-PPT%5d-210422.pdf)
|
|
func getUserEditAtomsData(currentUser, pptDocument *mscfb.File) (
|
|
persistDirectoryOffsets []int64,
|
|
liveRecord record,
|
|
err error,
|
|
) {
|
|
const (
|
|
offsetLastEditInitialPosition = 16
|
|
offsetLastEditPosition = 8
|
|
persistDirectoryOffsetPosition = 12
|
|
)
|
|
var b [4]byte
|
|
_, err = currentUser.ReadAt(b[:], offsetLastEditInitialPosition)
|
|
if err != nil {
|
|
return nil, record{}, err
|
|
}
|
|
offsetLastEdit := binary.LittleEndian.Uint32(b[:])
|
|
|
|
for {
|
|
liveRecord, err = readRecord(pptDocument, int64(offsetLastEdit), recordTypeUserEditAtom)
|
|
if err != nil {
|
|
if errors.Is(err, errMismatchRecordType) {
|
|
break
|
|
}
|
|
return nil, record{}, err
|
|
}
|
|
persistDirectoryOffsets = append(
|
|
persistDirectoryOffsets,
|
|
int64(liveRecord.LongAt(persistDirectoryOffsetPosition)),
|
|
)
|
|
offsetLastEdit = liveRecord.LongAt(offsetLastEditPosition)
|
|
if offsetLastEdit == 0 {
|
|
break
|
|
}
|
|
}
|
|
|
|
return persistDirectoryOffsets, liveRecord, err
|
|
}
|
|
|
|
// getPersistDirectoryEntries transforms offsets into persists directory identifiers and persist offsets according
|
|
// to section 2.1.2 of specification (https://msopenspecs.azureedge.net/files/MS-PPT/%5bMS-PPT%5d-210422.pdf)
|
|
func getPersistDirectoryEntries(pptDocument *mscfb.File, offsets []int64) (map[uint32]int64, error) {
|
|
const persistOffsetEntrySize = 4
|
|
|
|
persistDirEntries := make(map[uint32]int64)
|
|
for i := len(offsets) - 1; i >= 0; i-- {
|
|
rgPersistDirEntry, err := readRecord(pptDocument, offsets[i], recordTypePersistDirectoryAtom)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rgPersistDirEntryData := rgPersistDirEntry.recordData
|
|
|
|
for j := 0; j < len(rgPersistDirEntryData); {
|
|
persist := rgPersistDirEntryData.LongAt(j)
|
|
persistID := persist & 0x000FFFFF
|
|
cPersist := ((persist & 0xFFF00000) >> 20) & 0x00000FFF
|
|
j += 4
|
|
|
|
for k := uint32(0); k < cPersist; k++ {
|
|
persistDirEntries[persistID+k] = int64(rgPersistDirEntryData.LongAt(j + int(k)*persistOffsetEntrySize))
|
|
}
|
|
j += int(cPersist * persistOffsetEntrySize)
|
|
}
|
|
}
|
|
return persistDirEntries, nil
|
|
}
|
|
|
|
// readSlides reads text from slides of given DocumentContainer
|
|
func readSlides(documentContainer, pptDocument io.ReaderAt, persistDirEntries map[uint32]int64) (string, error) {
|
|
const slideSkipInitialOffset = 48
|
|
offset, err := skipRecords(documentContainer, slideSkipInitialOffset, slideSkippedRecordsTypes)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
slideList, err := readRecord(documentContainer, offset, recordTypeSlideListWithText)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
utf16Decoder := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewDecoder()
|
|
|
|
var out strings.Builder
|
|
n := len(slideList.Data())
|
|
for i := 0; i < n; {
|
|
block, err := readRecord(slideList, int64(i), recordTypeUnspecified)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
switch block.Type() {
|
|
case recordTypeSlidePersistAtom:
|
|
err = readTextFromSlidePersistAtom(block, pptDocument, persistDirEntries, &out, utf16Decoder)
|
|
case recordTypeTextCharsAtom:
|
|
err = readTextFromTextCharsAtom(block, &out, utf16Decoder)
|
|
case recordTypeTextBytesAtom:
|
|
err = readTextFromTextBytesAtom(block, &out, utf16Decoder)
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
i += len(block.Data()) + 8
|
|
}
|
|
|
|
return out.String(), nil
|
|
}
|
|
|
|
func readTextFromSlidePersistAtom(
|
|
block record,
|
|
pptDocument io.ReaderAt,
|
|
persistDirEntries map[uint32]int64,
|
|
out *strings.Builder,
|
|
utf16Decoder *encoding.Decoder,
|
|
) error {
|
|
const (
|
|
slidePersistAtomSkipInitialOffset = 32
|
|
headerRecordTypeOffset = 2
|
|
)
|
|
|
|
persistDirID := block.LongAt(0)
|
|
// extract slide from persist directory
|
|
slide, err := readRecord(pptDocument, persistDirEntries[persistDirID], recordTypeSlide)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// skip metadata
|
|
offset, err := skipRecords(slide, slidePersistAtomSkipInitialOffset, drawingSkippedRecordsTypes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
drawing, err := readRecord(slide, offset, recordTypeDrawing)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
drawingBytes := drawing.Data()
|
|
from := 0
|
|
for {
|
|
// instead of parsing binary PPT format, search text records directly
|
|
pocketIdx := matchPocket(drawingBytes, from)
|
|
if pocketIdx == -1 {
|
|
break
|
|
}
|
|
// check if it is really a text record - recordType bytes must be preceded by 1-byte version and 3-byte instance
|
|
// fields with zero values
|
|
if pocketIdx >= 2 && bytes.Equal(drawingBytes[pocketIdx-headerRecordTypeOffset:pocketIdx], []byte{0x00, 0x00}) {
|
|
var rec record
|
|
if drawingBytes[pocketIdx] == recordTypeTextBytesAtom.LowerPart() {
|
|
rec, err = readRecord(drawing, int64(pocketIdx-headerRecordTypeOffset), recordTypeTextBytesAtom)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = readTextFromTextBytesAtom(rec, out, utf16Decoder)
|
|
} else {
|
|
rec, err = readRecord(drawing, int64(pocketIdx-headerRecordTypeOffset), recordTypeTextCharsAtom)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = readTextFromTextCharsAtom(rec, out, utf16Decoder)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
from = pocketIdx + 2
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func matchPocket(data []byte, from int) int {
|
|
data = data[from:]
|
|
n := len(data)
|
|
for i := 0; i < n; i++ {
|
|
switch data[i] {
|
|
case recordTypeTextCharsAtom.LowerPart(), recordTypeTextBytesAtom.LowerPart():
|
|
if i < n-1 && data[i+1] == 0x0F {
|
|
return i + from
|
|
}
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// readTextFromTextCharsAtom simply transforms UTF-16LE data into UTF-8 data
|
|
func readTextFromTextCharsAtom(atom record, out *strings.Builder, dec *encoding.Decoder) error {
|
|
dec.Reset()
|
|
transformed, err := dec.Bytes(atom.Data())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out.Write(transformed)
|
|
out.WriteByte(' ')
|
|
return nil
|
|
}
|
|
|
|
func readTextFromTextBytesAtom(atom record, out *strings.Builder, dec *encoding.Decoder) error {
|
|
dec.Reset()
|
|
transformed, err := decodeTextBytesAtom(atom.Data(), dec)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out.Write(transformed)
|
|
out.WriteByte(' ')
|
|
return nil
|
|
}
|
|
|
|
// decodeTextBytesAtom transforms text from TextBytesAtom, which is an array of bytes representing lower parts of UTF-16
|
|
// characters into UTF-8 data
|
|
func decodeTextBytesAtom(data []byte, dec *encoding.Decoder) ([]byte, error) {
|
|
var (
|
|
// buffer for UTF-16 char
|
|
buf [2]byte
|
|
err error
|
|
)
|
|
result := make([]byte, 0, len(data))
|
|
for i := range data {
|
|
// filling upper part of character with zero
|
|
clear(buf[:])
|
|
// fill lower part with byte
|
|
buf[0] = data[i]
|
|
|
|
// transform single UTF-16 char into UTF-8 rune and append it into result
|
|
result, _, err = transform.Append(dec, result, buf[:])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// skipRecords reads headers and skips data of records of provided types
|
|
func skipRecords(r io.ReaderAt, initialOffset int64, skippedRecordsTypes []recordType) (int64, error) {
|
|
offset := initialOffset
|
|
|
|
for i := range skippedRecordsTypes {
|
|
rec, err := readRecordHeaderOnly(r, offset, skippedRecordsTypes[i])
|
|
if err != nil {
|
|
if errors.Is(err, errMismatchRecordType) {
|
|
continue
|
|
}
|
|
return 0, err
|
|
}
|
|
offset += int64(rec.Length() + headerSize)
|
|
}
|
|
|
|
return offset, nil
|
|
}
|
|
|