pptx-notes.ts 1.8 KB

12345678910111213141516171819202122232425262728293031323334
  1. import { readFile } from 'node:fs/promises'
  2. import JSZip from 'jszip'
  3. import { XMLParser } from 'fast-xml-parser'
  4. const parser = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true })
  5. const list = <T>(value: T | T[] | undefined): T[] => value === undefined ? [] : Array.isArray(value) ? value : [value]
  6. // Some PPTX producers store line breaks as literal character references inside note text.
  7. // Decode only those references, leaving other user-authored entities untouched.
  8. export const normalizePptxNoteBreaks = (value: string) => value.replace(/&#(?:10|x0*a);/gi, '\n')
  9. function textNodes(value: unknown): string[] {
  10. if (!value || typeof value !== 'object') return []
  11. return Object.entries(value).flatMap(([key, child]) => key === 't'
  12. ? [typeof child === 'string' ? child : String(child ?? '')]
  13. : Array.isArray(child) ? child.flatMap(textNodes) : textNodes(child))
  14. }
  15. export async function extractPptxSpeakerNotes(path: string): Promise<string[]> {
  16. const zip = await JSZip.loadAsync(await readFile(path), { checkCRC32: true })
  17. const files = Object.keys(zip.files).filter(name => /^ppt\/notesSlides\/notesSlide\d+\.xml$/.test(name))
  18. .sort((a, b) => Number(a.match(/\d+(?=\.xml$)/)![0]) - Number(b.match(/\d+(?=\.xml$)/)![0]))
  19. if (files.length > 500) throw new Error('PPTX 页数超出备注解析限制')
  20. const notes: string[] = []
  21. for (const name of files) {
  22. const xml = await zip.file(name)!.async('string')
  23. const shapes = list(parser.parse(xml)?.notes?.cSld?.spTree?.sp)
  24. const body = shapes.find((shape: any) => shape?.nvSpPr?.nvPr?.ph?.['@_type'] === 'body') as any
  25. const paragraphs = list(body?.txBody?.p)
  26. .map((paragraph: unknown) => normalizePptxNoteBreaks(textNodes(paragraph).join('')).trim())
  27. .filter(Boolean)
  28. notes.push(paragraphs.join('\n'))
  29. }
  30. return notes
  31. }