d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
290 lines
8.7 KiB
TypeScript
290 lines
8.7 KiB
TypeScript
/**
|
|
* EMF (Enhanced Metafile) binary parser — extracts embedded content from EMF files.
|
|
*
|
|
* PPTX files frequently embed EMF images as OLE object previews.
|
|
* Most contain embedded PDF data inside GDI comment records, or DIB bitmaps
|
|
* via STRETCHDIBITS records. This parser extracts those embedded resources
|
|
* without implementing full EMF record interpretation.
|
|
*
|
|
* EMF record format: each record is { type: u32, size: u32, ...data }
|
|
* Records are walked sequentially until EOF record (type 14).
|
|
*/
|
|
|
|
export type EmfContent =
|
|
| { type: 'pdf'; data: Uint8Array }
|
|
| { type: 'bitmap'; imageData: ImageData }
|
|
| { type: 'empty' }
|
|
| { type: 'unsupported' }
|
|
|
|
// EMF record types
|
|
const EMR_EOF = 14
|
|
const EMR_COMMENT = 70
|
|
const EMR_STRETCHDIBITS = 81
|
|
|
|
// GDI comment identifiers (MS-EMF spec)
|
|
const GDIC_COMMENT_ID = 0x43494447 // "GDIC"
|
|
const GDIC_BEGINGROUP = 0x00000002
|
|
const GDIC_MULTIFORMATS = 0x40000004
|
|
|
|
// EMF header signature at offset 40
|
|
const EMF_SIGNATURE = 0x464d4520 // " EMF"
|
|
|
|
// PDF markers
|
|
const PDF_HEADER = [0x25, 0x50, 0x44, 0x46] // "%PDF"
|
|
const PDF_EOF = [0x25, 0x25, 0x45, 0x4f, 0x46] // "%%EOF"
|
|
|
|
// DIB compression
|
|
const BI_RGB = 0
|
|
|
|
/**
|
|
* Parse an EMF file and extract its embedded content.
|
|
*/
|
|
export function parseEmfContent(data: Uint8Array): EmfContent {
|
|
if (data.length < 44) return { type: 'unsupported' }
|
|
|
|
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
|
|
|
// Validate EMF signature at offset 40
|
|
if (view.getUint32(40, true) !== EMF_SIGNATURE) {
|
|
return { type: 'unsupported' }
|
|
}
|
|
|
|
let offset = 0
|
|
let recordCount = 0
|
|
|
|
while (offset + 8 <= data.length) {
|
|
const recordType = view.getUint32(offset, true)
|
|
const recordSize = view.getUint32(offset + 4, true)
|
|
|
|
// Sanity check record size
|
|
if (recordSize < 8 || offset + recordSize > data.length) break
|
|
|
|
recordCount++
|
|
|
|
if (recordType === EMR_EOF) break
|
|
|
|
// Check GDI Comment records for embedded PDF
|
|
if (recordType === EMR_COMMENT && recordSize > 16) {
|
|
const result = parseGdiComment(data, view, offset, recordSize)
|
|
if (result) return result
|
|
}
|
|
|
|
// Check STRETCHDIBITS for embedded bitmaps
|
|
if (recordType === EMR_STRETCHDIBITS && recordSize > 80) {
|
|
const result = parseStretchDibits(data, view, offset, recordSize)
|
|
if (result) return result
|
|
}
|
|
|
|
offset += recordSize
|
|
}
|
|
|
|
// Only HEADER + EOF → empty
|
|
if (recordCount <= 2) {
|
|
return { type: 'empty' }
|
|
}
|
|
|
|
return { type: 'unsupported' }
|
|
}
|
|
|
|
/**
|
|
* Parse a GDI Comment record looking for embedded PDF data.
|
|
*/
|
|
function parseGdiComment(
|
|
data: Uint8Array,
|
|
view: DataView,
|
|
offset: number,
|
|
recordSize: number
|
|
): EmfContent | null {
|
|
// Record layout: type(4) + size(4) + cbData(4) + commentId(4) + ...
|
|
if (offset + 16 > data.length) return null
|
|
|
|
const commentId = view.getUint32(offset + 12, true)
|
|
|
|
if (commentId === GDIC_COMMENT_ID && offset + 20 <= data.length) {
|
|
const publicType = view.getUint32(offset + 16, true)
|
|
|
|
if (publicType === GDIC_BEGINGROUP) {
|
|
// Search for %PDF signature in the record data
|
|
const recordData = data.subarray(offset + 8, offset + recordSize)
|
|
const pdf = extractPdfFromBuffer(recordData)
|
|
if (pdf) return { type: 'pdf', data: pdf }
|
|
}
|
|
|
|
if (publicType === GDIC_MULTIFORMATS && offset + 24 <= data.length) {
|
|
// MULTIFORMATS: parse format descriptors and extract first usable one
|
|
const result = parseMultiformats(data, view, offset, recordSize)
|
|
if (result) return result
|
|
}
|
|
}
|
|
|
|
// Also search non-GDIC comments for raw PDF data
|
|
if (recordSize > 100) {
|
|
const recordData = data.subarray(offset + 8, offset + recordSize)
|
|
const pdf = extractPdfFromBuffer(recordData)
|
|
if (pdf) return { type: 'pdf', data: pdf }
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Parse MULTIFORMATS GDI comment — contains format descriptors pointing to embedded data.
|
|
*/
|
|
function parseMultiformats(
|
|
data: Uint8Array,
|
|
view: DataView,
|
|
offset: number,
|
|
_recordSize: number
|
|
): EmfContent | null {
|
|
// Layout from record start:
|
|
// +12: commentIdentifier(4), +16: publicCommentIdentifier(4)
|
|
// +20: outputRect(16 = RECTL)
|
|
// +36: countFormats(4)
|
|
// +40: format descriptors array, each: { signature(4), version(4), cbData(4), offData(4) }
|
|
if (offset + 40 > data.length) return null
|
|
|
|
const countFormats = view.getUint32(offset + 36, true)
|
|
const descriptorStart = offset + 40
|
|
|
|
for (let i = 0; i < countFormats && i < 10; i++) {
|
|
const descOff = descriptorStart + i * 16
|
|
if (descOff + 16 > data.length) break
|
|
|
|
const cbData = view.getUint32(descOff + 8, true)
|
|
const offData = view.getUint32(descOff + 12, true)
|
|
|
|
// offData is relative to the start of the record
|
|
const dataStart = offset + offData
|
|
if (dataStart + cbData > data.length || cbData === 0) continue
|
|
|
|
const formatData = data.subarray(dataStart, dataStart + cbData)
|
|
const pdf = extractPdfFromBuffer(formatData)
|
|
if (pdf) return { type: 'pdf', data: pdf }
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Search for %PDF...%%EOF in a buffer and extract the PDF bytes.
|
|
*/
|
|
function extractPdfFromBuffer(buf: Uint8Array): Uint8Array | null {
|
|
const pdfStart = findSequence(buf, PDF_HEADER)
|
|
if (pdfStart === -1) return null
|
|
|
|
// Search for %%EOF from the end (PDF may have multiple %%EOF; take the last one)
|
|
let pdfEnd = -1
|
|
for (let i = buf.length - PDF_EOF.length; i >= pdfStart; i--) {
|
|
if (matchesAt(buf, i, PDF_EOF)) {
|
|
pdfEnd = i + PDF_EOF.length
|
|
break
|
|
}
|
|
}
|
|
|
|
if (pdfEnd === -1) {
|
|
// No %%EOF found — take everything from %PDF to end of buffer
|
|
pdfEnd = buf.length
|
|
}
|
|
|
|
return buf.slice(pdfStart, pdfEnd)
|
|
}
|
|
|
|
/**
|
|
* Parse a STRETCHDIBITS record and extract the bitmap as ImageData.
|
|
*/
|
|
function parseStretchDibits(
|
|
data: Uint8Array,
|
|
view: DataView,
|
|
offset: number,
|
|
_recordSize: number
|
|
): EmfContent | null {
|
|
// STRETCHDIBITS record layout (offsets from record start):
|
|
// 0: type(4), 4: size(4)
|
|
// 8: rclBounds (16 bytes)
|
|
// 24: xDest(4), 28: yDest(4)
|
|
// 32: xSrc(4), 36: ySrc(4)
|
|
// 40: cxSrc(4), 44: cySrc(4)
|
|
// 48: offBmiSrc(4), 52: cbBmiSrc(4)
|
|
// 56: offBitsSrc(4), 60: cbBitsSrc(4)
|
|
// 64: iUsageSrc(4), 68: dwRop(4)
|
|
// 72: cxDest(4), 76: cyDest(4)
|
|
if (offset + 80 > data.length) return null
|
|
|
|
const offBmiSrc = view.getUint32(offset + 48, true)
|
|
const cbBmiSrc = view.getUint32(offset + 52, true)
|
|
const offBitsSrc = view.getUint32(offset + 56, true)
|
|
const cbBitsSrc = view.getUint32(offset + 60, true)
|
|
|
|
if (cbBmiSrc === 0 || cbBitsSrc === 0) return null
|
|
|
|
const bmiStart = offset + offBmiSrc
|
|
if (bmiStart + 40 > data.length) return null
|
|
|
|
// Parse BITMAPINFOHEADER
|
|
const biWidth = view.getInt32(bmiStart + 4, true)
|
|
const biHeight = view.getInt32(bmiStart + 8, true)
|
|
const biBitCount = view.getUint16(bmiStart + 14, true)
|
|
const biCompression = view.getUint32(bmiStart + 16, true)
|
|
|
|
// Only support uncompressed RGB bitmaps
|
|
if (biCompression !== BI_RGB) return null
|
|
if (biBitCount !== 24 && biBitCount !== 32) return null
|
|
|
|
const width = Math.abs(biWidth)
|
|
const height = Math.abs(biHeight)
|
|
if (width === 0 || height === 0 || width > 8192 || height > 8192) return null
|
|
|
|
const bitsStart = offset + offBitsSrc
|
|
if (bitsStart + cbBitsSrc > data.length) return null
|
|
|
|
const bitsData = data.subarray(bitsStart, bitsStart + cbBitsSrc)
|
|
|
|
// Negative height means top-down row order; positive means bottom-up
|
|
const topDown = biHeight < 0
|
|
|
|
const imageData = new ImageData(width, height)
|
|
const bytesPerPixel = biBitCount / 8
|
|
// DIB rows are padded to 4-byte boundaries
|
|
const rowStride = Math.ceil((width * bytesPerPixel) / 4) * 4
|
|
|
|
for (let y = 0; y < height; y++) {
|
|
const srcRow = topDown ? y : height - 1 - y
|
|
const srcOffset = srcRow * rowStride
|
|
const dstOffset = y * width * 4
|
|
|
|
for (let x = 0; x < width; x++) {
|
|
const srcIdx = srcOffset + x * bytesPerPixel
|
|
if (srcIdx + bytesPerPixel > bitsData.length) break
|
|
|
|
// DIB stores BGR(A)
|
|
imageData.data[dstOffset + x * 4 + 0] = bitsData[srcIdx + 2] // R
|
|
imageData.data[dstOffset + x * 4 + 1] = bitsData[srcIdx + 1] // G
|
|
imageData.data[dstOffset + x * 4 + 2] = bitsData[srcIdx + 0] // B
|
|
imageData.data[dstOffset + x * 4 + 3] = biBitCount === 32 ? bitsData[srcIdx + 3] : 255
|
|
}
|
|
}
|
|
|
|
return { type: 'bitmap', imageData }
|
|
}
|
|
|
|
/**
|
|
* Find the first occurrence of a byte sequence in a buffer.
|
|
*/
|
|
function findSequence(buf: Uint8Array, seq: number[]): number {
|
|
const end = buf.length - seq.length
|
|
for (let i = 0; i <= end; i++) {
|
|
if (matchesAt(buf, i, seq)) return i
|
|
}
|
|
return -1
|
|
}
|
|
|
|
/**
|
|
* Check if buffer matches a byte sequence at a given offset.
|
|
*/
|
|
function matchesAt(buf: Uint8Array, offset: number, seq: number[]): boolean {
|
|
for (let j = 0; j < seq.length; j++) {
|
|
if (buf[offset + j] !== seq[j]) return false
|
|
}
|
|
return true
|
|
}
|