chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
// ============================================================================
|
||||
// OOXML Color Utilities
|
||||
// Full color manipulation for PowerPoint XML color processing
|
||||
// ============================================================================
|
||||
|
||||
export { hexToRgb, hslToRgb, rgbToHex, rgbToHsl, toCssColor } from '@/lib/colors'
|
||||
|
||||
import { hexToRgb, hslToRgb, rgbToHex, rgbToHsl } from '@/lib/colors'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sRGB ↔ Linear RGB conversion (IEC 61966-2-1)
|
||||
// PowerPoint applies tint/shade in linear (scene-referred) space.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function srgbToLinear(c: number): number {
|
||||
const s = c / 255
|
||||
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
|
||||
function linearToSrgb(c: number): number {
|
||||
const s = c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055
|
||||
return Math.max(0, Math.min(255, Math.round(s * 255)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OOXML Color Modifiers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply tint modifier (mix toward white in linear RGB space).
|
||||
* OOXML spec: tint val is 0-100000 where 100000 = original color, 0 = fully white.
|
||||
* PowerPoint performs the blend in linear RGB space for perceptual correctness.
|
||||
*/
|
||||
export function applyTint(hex: string, tint: number): string {
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
const t = tint / 100000
|
||||
const rl = srgbToLinear(r)
|
||||
const gl = srgbToLinear(g)
|
||||
const bl = srgbToLinear(b)
|
||||
return rgbToHex(
|
||||
linearToSrgb(rl * t + 1.0 * (1 - t)),
|
||||
linearToSrgb(gl * t + 1.0 * (1 - t)),
|
||||
linearToSrgb(bl * t + 1.0 * (1 - t))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply shade modifier (mix toward black in linear RGB space).
|
||||
* shade: 0-100000 where 100000 = original color, 0 = fully black.
|
||||
*/
|
||||
export function applyShade(hex: string, shade: number): string {
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
const s = shade / 100000
|
||||
return rgbToHex(
|
||||
linearToSrgb(srgbToLinear(r) * s),
|
||||
linearToSrgb(srgbToLinear(g) * s),
|
||||
linearToSrgb(srgbToLinear(b) * s)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply luminance modulation.
|
||||
* lumMod: percentage in OOXML units (e.g., 75000 = 75%).
|
||||
* Multiplies the L channel of HSL.
|
||||
*/
|
||||
export function applyLumMod(hex: string, lumMod: number): string {
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
const { h, s, l } = rgbToHsl(r, g, b)
|
||||
const newL = Math.max(0, Math.min(1, l * (lumMod / 100000)))
|
||||
const rgb = hslToRgb(h, s, newL)
|
||||
return rgbToHex(rgb.r, rgb.g, rgb.b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply luminance offset.
|
||||
* lumOff: percentage offset in OOXML units (e.g., 25000 = +25%).
|
||||
* Adds to the L channel of HSL.
|
||||
*/
|
||||
export function applyLumOff(hex: string, lumOff: number): string {
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
const { h, s, l } = rgbToHsl(r, g, b)
|
||||
const newL = Math.max(0, Math.min(1, l + lumOff / 100000))
|
||||
const rgb = hslToRgb(h, s, newL)
|
||||
return rgbToHex(rgb.r, rgb.g, rgb.b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply saturation modulation.
|
||||
* satMod: percentage in OOXML units (e.g., 120000 = 120%).
|
||||
* Multiplies the S channel of HSL.
|
||||
*/
|
||||
export function applySatMod(hex: string, satMod: number): string {
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
const { h, s, l } = rgbToHsl(r, g, b)
|
||||
const newS = Math.max(0, Math.min(1, s * (satMod / 100000)))
|
||||
const rgb = hslToRgb(h, newS, l)
|
||||
return rgbToHex(rgb.r, rgb.g, rgb.b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply hue modulation.
|
||||
* hueMod: percentage in OOXML units (e.g., 60000 = shift hue by ratio).
|
||||
* In OOXML, hueMod multiplies the hue value. Hue wraps around at 360.
|
||||
*/
|
||||
export function applyHueMod(hex: string, hueMod: number): string {
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
const { h, s, l } = rgbToHsl(r, g, b)
|
||||
const newH = (h * (hueMod / 100000)) % 360
|
||||
const rgb = hslToRgb(newH, s, l)
|
||||
return rgbToHex(rgb.r, rgb.g, rgb.b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply hue offset (additive).
|
||||
* hueOff: in 60000ths of a degree (OOXML ST_FixedAngle).
|
||||
* Adds to the hue channel of HSL, wrapping at 360.
|
||||
*/
|
||||
export function applyHueOff(hex: string, hueOff: number): string {
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
const { h, s, l } = rgbToHsl(r, g, b)
|
||||
const offsetDeg = hueOff / 60000
|
||||
const newH = (((h + offsetDeg) % 360) + 360) % 360
|
||||
const rgb = hslToRgb(newH, s, l)
|
||||
return rgbToHex(rgb.r, rgb.g, rgb.b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply saturation offset (additive).
|
||||
* satOff: in OOXML percentage units (100000 = 100%).
|
||||
* Adds to the S channel of HSL.
|
||||
*/
|
||||
export function applySatOff(hex: string, satOff: number): string {
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
const { h, s, l } = rgbToHsl(r, g, b)
|
||||
const newS = Math.max(0, Math.min(1, s + satOff / 100000))
|
||||
const rgb = hslToRgb(h, newS, l)
|
||||
return rgbToHex(rgb.r, rgb.g, rgb.b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert OOXML alpha value (0-100000) to CSS opacity (0-1).
|
||||
* 100000 = fully opaque, 0 = fully transparent.
|
||||
*/
|
||||
export function applyAlpha(alpha: number): number {
|
||||
return Math.max(0, Math.min(1, alpha / 100000))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composite Modifier Application
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ColorModifier {
|
||||
name: string
|
||||
val: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all OOXML color modifiers from an array of {name, val} objects.
|
||||
* Modifiers are applied in the order they appear (matching XML document order).
|
||||
* Returns the final hex color and alpha value.
|
||||
*/
|
||||
export function applyColorModifiers(
|
||||
hex: string,
|
||||
modifiers: ColorModifier[]
|
||||
): { color: string; alpha: number } {
|
||||
let color = hex
|
||||
let alpha = 1
|
||||
|
||||
for (const mod of modifiers) {
|
||||
switch (mod.name) {
|
||||
case 'tint':
|
||||
case 'a:tint':
|
||||
color = applyTint(color, mod.val)
|
||||
break
|
||||
case 'shade':
|
||||
case 'a:shade':
|
||||
color = applyShade(color, mod.val)
|
||||
break
|
||||
case 'lumMod':
|
||||
case 'a:lumMod':
|
||||
color = applyLumMod(color, mod.val)
|
||||
break
|
||||
case 'lumOff':
|
||||
case 'a:lumOff':
|
||||
color = applyLumOff(color, mod.val)
|
||||
break
|
||||
case 'satMod':
|
||||
case 'a:satMod':
|
||||
color = applySatMod(color, mod.val)
|
||||
break
|
||||
case 'hueMod':
|
||||
case 'a:hueMod':
|
||||
color = applyHueMod(color, mod.val)
|
||||
break
|
||||
case 'hueOff':
|
||||
case 'a:hueOff':
|
||||
color = applyHueOff(color, mod.val)
|
||||
break
|
||||
case 'satOff':
|
||||
case 'a:satOff':
|
||||
color = applySatOff(color, mod.val)
|
||||
break
|
||||
case 'alpha':
|
||||
case 'a:alpha':
|
||||
alpha = applyAlpha(mod.val)
|
||||
break
|
||||
case 'alphaOff':
|
||||
case 'a:alphaOff':
|
||||
alpha = Math.max(0, Math.min(1, alpha + mod.val / 100000))
|
||||
break
|
||||
default:
|
||||
// Unknown modifier - skip silently
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return { color, alpha }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OOXML Preset Color Table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PRESET_COLORS: Record<string, string> = {
|
||||
// Basic colors
|
||||
black: '#000000',
|
||||
white: '#FFFFFF',
|
||||
red: '#FF0000',
|
||||
green: '#008000',
|
||||
blue: '#0000FF',
|
||||
yellow: '#FFFF00',
|
||||
cyan: '#00FFFF',
|
||||
magenta: '#FF00FF',
|
||||
|
||||
// Extended standard colors
|
||||
orange: '#FFA500',
|
||||
purple: '#800080',
|
||||
brown: '#A52A2A',
|
||||
pink: '#FFC0CB',
|
||||
gray: '#808080',
|
||||
grey: '#808080',
|
||||
lime: '#00FF00',
|
||||
navy: '#000080',
|
||||
teal: '#008080',
|
||||
maroon: '#800000',
|
||||
olive: '#808000',
|
||||
silver: '#C0C0C0',
|
||||
aqua: '#00FFFF',
|
||||
fuchsia: '#FF00FF',
|
||||
|
||||
// OOXML-specific preset colors
|
||||
aliceBlue: '#F0F8FF',
|
||||
antiqueWhite: '#FAEBD7',
|
||||
aquamarine: '#7FFFD4',
|
||||
azure: '#F0FFFF',
|
||||
beige: '#F5F5DC',
|
||||
bisque: '#FFE4C4',
|
||||
blanchedAlmond: '#FFEBCD',
|
||||
blueViolet: '#8A2BE2',
|
||||
burlyWood: '#DEB887',
|
||||
cadetBlue: '#5F9EA0',
|
||||
chartreuse: '#7FFF00',
|
||||
chocolate: '#D2691E',
|
||||
coral: '#FF7F50',
|
||||
cornflowerBlue: '#6495ED',
|
||||
cornsilk: '#FFF8DC',
|
||||
crimson: '#DC143C',
|
||||
darkBlue: '#00008B',
|
||||
darkCyan: '#008B8B',
|
||||
darkGoldenrod: '#B8860B',
|
||||
darkGray: '#A9A9A9',
|
||||
darkGrey: '#A9A9A9',
|
||||
darkGreen: '#006400',
|
||||
darkKhaki: '#BDB76B',
|
||||
darkMagenta: '#8B008B',
|
||||
darkOliveGreen: '#556B2F',
|
||||
darkOrange: '#FF8C00',
|
||||
darkOrchid: '#9932CC',
|
||||
darkRed: '#8B0000',
|
||||
darkSalmon: '#E9967A',
|
||||
darkSeaGreen: '#8FBC8F',
|
||||
darkSlateBlue: '#483D8B',
|
||||
darkSlateGray: '#2F4F4F',
|
||||
darkSlateGrey: '#2F4F4F',
|
||||
darkTurquoise: '#00CED1',
|
||||
darkViolet: '#9400D3',
|
||||
deepPink: '#FF1493',
|
||||
deepSkyBlue: '#00BFFF',
|
||||
dimGray: '#696969',
|
||||
dimGrey: '#696969',
|
||||
dodgerBlue: '#1E90FF',
|
||||
firebrick: '#B22222',
|
||||
floralWhite: '#FFFAF0',
|
||||
forestGreen: '#228B22',
|
||||
gainsboro: '#DCDCDC',
|
||||
ghostWhite: '#F8F8FF',
|
||||
gold: '#FFD700',
|
||||
goldenrod: '#DAA520',
|
||||
greenYellow: '#ADFF2F',
|
||||
honeydew: '#F0FFF0',
|
||||
hotPink: '#FF69B4',
|
||||
indianRed: '#CD5C5C',
|
||||
indigo: '#4B0082',
|
||||
ivory: '#FFFFF0',
|
||||
khaki: '#F0E68C',
|
||||
lavender: '#E6E6FA',
|
||||
lavenderBlush: '#FFF0F5',
|
||||
lawnGreen: '#7CFC00',
|
||||
lemonChiffon: '#FFFACD',
|
||||
lightBlue: '#ADD8E6',
|
||||
lightCoral: '#F08080',
|
||||
lightCyan: '#E0FFFF',
|
||||
lightGoldenrodYellow: '#FAFAD2',
|
||||
lightGray: '#D3D3D3',
|
||||
lightGrey: '#D3D3D3',
|
||||
lightGreen: '#90EE90',
|
||||
lightPink: '#FFB6C1',
|
||||
lightSalmon: '#FFA07A',
|
||||
lightSeaGreen: '#20B2AA',
|
||||
lightSkyBlue: '#87CEFA',
|
||||
lightSlateGray: '#778899',
|
||||
lightSlateGrey: '#778899',
|
||||
lightSteelBlue: '#B0C4DE',
|
||||
lightYellow: '#FFFFE0',
|
||||
limeGreen: '#32CD32',
|
||||
linen: '#FAF0E6',
|
||||
mediumAquamarine: '#66CDAA',
|
||||
mediumBlue: '#0000CD',
|
||||
mediumOrchid: '#BA55D3',
|
||||
mediumPurple: '#9370DB',
|
||||
mediumSeaGreen: '#3CB371',
|
||||
mediumSlateBlue: '#7B68EE',
|
||||
mediumSpringGreen: '#00FA9A',
|
||||
mediumTurquoise: '#48D1CC',
|
||||
mediumVioletRed: '#C71585',
|
||||
midnightBlue: '#191970',
|
||||
mintCream: '#F5FFFA',
|
||||
mistyRose: '#FFE4E1',
|
||||
moccasin: '#FFE4B5',
|
||||
navajoWhite: '#FFDEAD',
|
||||
oldLace: '#FDF5E6',
|
||||
oliveDrab: '#6B8E23',
|
||||
orangeRed: '#FF4500',
|
||||
orchid: '#DA70D6',
|
||||
paleGoldenrod: '#EEE8AA',
|
||||
paleGreen: '#98FB98',
|
||||
paleTurquoise: '#AFEEEE',
|
||||
paleVioletRed: '#DB7093',
|
||||
papayaWhip: '#FFEFD5',
|
||||
peachPuff: '#FFDAB9',
|
||||
peru: '#CD853F',
|
||||
plum: '#DDA0DD',
|
||||
powderBlue: '#B0E0E6',
|
||||
rosyBrown: '#BC8F8F',
|
||||
royalBlue: '#4169E1',
|
||||
saddleBrown: '#8B4513',
|
||||
salmon: '#FA8072',
|
||||
sandyBrown: '#F4A460',
|
||||
seaGreen: '#2E8B57',
|
||||
seaShell: '#FFF5EE',
|
||||
sienna: '#A0522D',
|
||||
skyBlue: '#87CEEB',
|
||||
slateBlue: '#6A5ACD',
|
||||
slateGray: '#708090',
|
||||
slateGrey: '#708090',
|
||||
snow: '#FFFAFA',
|
||||
springGreen: '#00FF7F',
|
||||
steelBlue: '#4682B4',
|
||||
tan: '#D2B48C',
|
||||
thistle: '#D8BFD8',
|
||||
tomato: '#FF6347',
|
||||
turquoise: '#40E0D0',
|
||||
violet: '#EE82EE',
|
||||
wheat: '#F5DEB3',
|
||||
whiteSmoke: '#F5F5F5',
|
||||
yellowGreen: '#9ACD32',
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a preset OOXML color name and return its hex value.
|
||||
* Returns undefined if the name is not recognized.
|
||||
*/
|
||||
export function presetColorToHex(name: string): string | undefined {
|
||||
// Try exact match first, then case-insensitive
|
||||
if (PRESET_COLORS[name] !== undefined) {
|
||||
return PRESET_COLORS[name]
|
||||
}
|
||||
const lower = name.toLowerCase()
|
||||
for (const [key, value] of Object.entries(PRESET_COLORS)) {
|
||||
if (key.toLowerCase() === lower) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Media utilities — MIME type detection, path resolution, and blob URL management.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Determine MIME type from file extension.
|
||||
* Covers images, video, and audio formats used in PPTX files.
|
||||
*/
|
||||
export function getMimeType(path: string): string {
|
||||
const ext = path.split('.').pop()?.toLowerCase() || ''
|
||||
const mimeMap: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
svg: 'image/svg+xml',
|
||||
bmp: 'image/bmp',
|
||||
tiff: 'image/tiff',
|
||||
tif: 'image/tiff',
|
||||
emf: 'image/x-emf',
|
||||
wmf: 'image/x-wmf',
|
||||
webp: 'image/webp',
|
||||
mp4: 'video/mp4',
|
||||
m4v: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
avi: 'video/x-msvideo',
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
m4a: 'audio/mp4',
|
||||
ogg: 'audio/ogg',
|
||||
}
|
||||
return mimeMap[ext] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a relative media path (from rels) to its canonical path in PptxFiles.media.
|
||||
* Rels targets are relative like "../media/image1.png".
|
||||
* Media paths in PptxFiles are like "ppt/media/image1.png".
|
||||
*/
|
||||
export function resolveMediaPath(target: string): string {
|
||||
const fileName = target.split('/').pop() || ''
|
||||
return `ppt/media/${fileName}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a blob URL for a media file, using a cache to avoid duplicates.
|
||||
*
|
||||
* @param mediaPath - Canonical path (e.g. "ppt/media/image1.png")
|
||||
* @param data - Raw media data (Uint8Array or ArrayBuffer)
|
||||
* @param cache - Map to store/retrieve cached blob URLs
|
||||
* @returns The blob URL string
|
||||
*/
|
||||
export function getOrCreateBlobUrl(
|
||||
mediaPath: string,
|
||||
data: Uint8Array | ArrayBuffer,
|
||||
cache: Map<string, string>
|
||||
): string {
|
||||
let url = cache.get(mediaPath)
|
||||
if (!url) {
|
||||
const mime = getMimeType(mediaPath)
|
||||
const blobPart = data instanceof ArrayBuffer ? data : copyToArrayBuffer(data)
|
||||
const blob = new Blob([blobPart], { type: mime })
|
||||
url = URL.createObjectURL(blob)
|
||||
cache.set(mediaPath, url)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
function copyToArrayBuffer(data: Uint8Array): ArrayBuffer {
|
||||
const copy = new Uint8Array(data.byteLength)
|
||||
copy.set(data)
|
||||
return copy.buffer
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* PDF-to-image renderer for embedded EMF PDFs.
|
||||
*
|
||||
* pdfjs-dist v5 has process-level shared state (PagesMapper.#pagesNumber,
|
||||
* GlobalWorkerOptions.workerSrc, PDFWorker.#isWorkerDisabled) that a library
|
||||
* must never touch on the main thread — doing so clobbers the host app's pdfjs
|
||||
* configuration.
|
||||
*
|
||||
* Solution: render EMF PDFs exclusively inside a dedicated Web Worker. The
|
||||
* worker loads its OWN pdfjs instance via dynamic import, so all static state
|
||||
* is fully isolated from the main thread.
|
||||
*
|
||||
* If Worker + OffscreenCanvas are unavailable (extremely rare in 2025+
|
||||
* browsers), rendering is skipped and the caller gets null — no main-thread
|
||||
* fallback, no global state pollution.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolved pdfjs URL — computed once from main thread's module resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _pdfjsUrl: string | null = null
|
||||
|
||||
function getPdfjsUrl(): string | null {
|
||||
if (_pdfjsUrl !== null) return _pdfjsUrl
|
||||
try {
|
||||
// Resolve via the bundler/dev server so the URL is usable from a Worker
|
||||
_pdfjsUrl = new URL('pdfjs-dist/build/pdf.min.mjs', import.meta.url).toString()
|
||||
} catch {
|
||||
_pdfjsUrl = ''
|
||||
}
|
||||
return _pdfjsUrl || null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Worker-based renderer (fully isolated from main thread pdfjs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Inline source for the PDF render worker.
|
||||
* Receives: { id, pdfData, width, height, pdfjsUrl }
|
||||
* Posts back: { id, blob } or { id, error }
|
||||
*
|
||||
* The worker loads its OWN pdfjs instance via dynamic import, so its static
|
||||
* PagesMapper state is completely independent of the main thread.
|
||||
* pdfjs's own internal worker is disabled (workerPort = null, workerSrc = '')
|
||||
* so pdfjs runs single-threaded inside this worker — acceptable for tiny
|
||||
* 1-page EMF PDFs.
|
||||
*/
|
||||
const WORKER_SRC = /* js */ `
|
||||
let pdfjsLib = null;
|
||||
|
||||
self.onmessage = async (e) => {
|
||||
const { id, pdfData, width, height, pdfjsUrl } = e.data;
|
||||
try {
|
||||
if (!pdfjsLib) {
|
||||
pdfjsLib = await import(pdfjsUrl);
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = '';
|
||||
}
|
||||
|
||||
const doc = await pdfjsLib.getDocument({ data: pdfData }).promise;
|
||||
try {
|
||||
if (doc.numPages < 1) {
|
||||
self.postMessage({ id, error: 'no pages' });
|
||||
return;
|
||||
}
|
||||
const page = await doc.getPage(1);
|
||||
const vp = page.getViewport({ scale: 1 });
|
||||
const scale = Math.max(width / vp.width, height / vp.height);
|
||||
const svp = page.getViewport({ scale });
|
||||
|
||||
const canvas = new OffscreenCanvas(Math.ceil(svp.width), Math.ceil(svp.height));
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
await page.render({ canvasContext: ctx, viewport: svp, background: 'rgba(0,0,0,0)' }).promise;
|
||||
|
||||
const blob = await canvas.convertToBlob({ type: 'image/png' });
|
||||
self.postMessage({ id, blob });
|
||||
} finally {
|
||||
doc.destroy();
|
||||
}
|
||||
} catch (err) {
|
||||
self.postMessage({ id, error: String(err) });
|
||||
}
|
||||
};
|
||||
`
|
||||
|
||||
let _worker: Worker | null = null
|
||||
let _workerFailed = false
|
||||
let _msgId = 0
|
||||
const _pending = new Map<
|
||||
number,
|
||||
{ resolve: (b: Blob | null) => void; reject: (e: Error) => void }
|
||||
>()
|
||||
|
||||
function getWorker(_pdfjsUrl: string): Worker | null {
|
||||
if (_workerFailed) return null
|
||||
if (_worker) return _worker
|
||||
|
||||
try {
|
||||
const blob = new Blob([WORKER_SRC], { type: 'text/javascript' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
_worker = new Worker(url, { type: 'module' })
|
||||
|
||||
_worker.onmessage = (e: MessageEvent) => {
|
||||
const { id, blob, error } = e.data
|
||||
const entry = _pending.get(id)
|
||||
if (!entry) return
|
||||
_pending.delete(id)
|
||||
if (error) {
|
||||
entry.resolve(null) // Treat worker-side errors as "no result"
|
||||
} else {
|
||||
entry.resolve(blob ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
_worker.onerror = () => {
|
||||
// Worker failed to initialize (e.g. module import blocked by CSP)
|
||||
_workerFailed = true
|
||||
_worker = null
|
||||
for (const [, entry] of _pending) {
|
||||
entry.resolve(null)
|
||||
}
|
||||
_pending.clear()
|
||||
}
|
||||
|
||||
return _worker
|
||||
} catch {
|
||||
_workerFailed = true
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function renderInWorker(
|
||||
pdfData: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
pdfjsUrl: string
|
||||
): Promise<Blob | null> {
|
||||
return new Promise((resolve) => {
|
||||
const worker = getWorker(pdfjsUrl)
|
||||
if (!worker) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
|
||||
const id = ++_msgId
|
||||
_pending.set(id, {
|
||||
resolve,
|
||||
reject: () => resolve(null),
|
||||
})
|
||||
|
||||
// Transfer the buffer to avoid copying
|
||||
const copy = pdfData.slice() // copy so caller retains original
|
||||
worker.postMessage({ id, pdfData: copy, width, height, pdfjsUrl }, [copy.buffer])
|
||||
|
||||
// Timeout: if worker doesn't respond in 15s, give up
|
||||
setTimeout(() => {
|
||||
if (_pending.has(id)) {
|
||||
_pending.delete(id)
|
||||
resolve(null)
|
||||
}
|
||||
}, 15000)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Render page 1 of a PDF to a blob URL image.
|
||||
*
|
||||
* Uses a dedicated Web Worker with its own pdfjs instance, fully isolated
|
||||
* from the main thread. Never touches GlobalWorkerOptions or any other
|
||||
* pdfjs global state on the main thread.
|
||||
*
|
||||
* @returns blob URL string, or null if rendering fails or Worker is unavailable
|
||||
*/
|
||||
export async function renderPdfToImage(
|
||||
pdfData: Uint8Array,
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<string | null> {
|
||||
const pdfjsUrl = getPdfjsUrl()
|
||||
|
||||
if (!pdfjsUrl || typeof OffscreenCanvas === 'undefined' || typeof Worker === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await renderInWorker(pdfData, width, height, pdfjsUrl)
|
||||
if (blob) return URL.createObjectURL(blob)
|
||||
} catch {
|
||||
// Worker failed — no fallback, return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user