mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
feat: add PPTX/Word file upload and parsing support
- Add document-parser.js module for parsing PPTX and Word documents * Word parsing using mammoth library (extracts text + images) * PPTX parsing using jszip (extracts slide text + media images) * Automatic extraction of text to .txt files * Automatic extraction of embedded images * File validation (type and 10MB size limit) - Update server.js upload endpoint to automatically parse documents * Detect document files by extension * Extract and save text content as separate file * Extract and save all embedded images * Graceful fallback if parsing fails - Update ChatComposer.tsx to accept document uploads * Add accept attribute for .docx, .doc, .pptx, .ppt files * Add looksLikeDocument() helper function * Log document processing feedback to console - Update projects.js with document MIME types and kind * Add MIME types for Word/PPTX files * Add 'document' kind bucket for file classification - Add dependencies: mammoth, jszip, pizzip Closes #42
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
# PPTX/Word File Upload Implementation
|
||||
|
||||
## Summary
|
||||
|
||||
This PR adds support for uploading PPTX and Word documents to Open Design, automatically extracting text and images for use in design generation workflows.
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. New Document Parser Module (`daemon/document-parser.js`)
|
||||
- **Word (.docx, .doc) parsing** using `mammoth` library
|
||||
- Extracts plain text content
|
||||
- Extracts embedded images
|
||||
- Provides metadata (word count, paragraph count)
|
||||
|
||||
- **PowerPoint (.pptx) parsing** using `jszip`
|
||||
- Extracts text from all slides
|
||||
- Extracts images from media folder
|
||||
- Provides slide-by-slide breakdown
|
||||
- Metadata includes slide count and word count
|
||||
|
||||
- **Validation**
|
||||
- File type checking
|
||||
- Size limit enforcement (10MB max)
|
||||
- Error handling with fallback to raw file upload
|
||||
|
||||
### 2. Backend Integration (`daemon/server.js`)
|
||||
**Important: Manual edits required**
|
||||
|
||||
Add this import near the top:
|
||||
```javascript
|
||||
import {
|
||||
isDocumentFile,
|
||||
parseDocument,
|
||||
validateDocument,
|
||||
} from './document-parser.js';
|
||||
```
|
||||
|
||||
Update the `/api/projects/:id/upload` endpoint (around line 663) to:
|
||||
1. Detect document files using `isDocumentFile()`
|
||||
2. Call `parseDocument()` to extract content
|
||||
3. Save extracted text as `<filename>-extracted-text.txt`
|
||||
4. Save extracted images with sanitized names
|
||||
5. Return both original file and extracted files in response
|
||||
|
||||
See the implementation in the patch file or the updated server.js in this branch.
|
||||
|
||||
### 3. Frontend Updates
|
||||
|
||||
#### `src/components/ChatComposer.tsx`
|
||||
- Added `accept="image/*,.pdf,.docx,.doc,.pptx,.ppt"` to file input
|
||||
- Added `looksLikeDocument()` helper function
|
||||
- Enhanced `uploadFiles()` to detect and log document processing
|
||||
- Improved user feedback for document uploads
|
||||
|
||||
#### `daemon/projects.js`
|
||||
- Added MIME types for document files:
|
||||
- `.docx`: `application/vnd.openxmlformats-officedocument.wordprocessingml.document`
|
||||
- `.doc`: `application/msword`
|
||||
- `.pptx`: `application/vnd.openxmlformats-officedocument.presentationml.presentation`
|
||||
- `.ppt`: `application/vnd.ms-powerpoint`
|
||||
- `.pdf`: `application/pdf`
|
||||
- Updated `kindFor()` to recognize `'document'` file kind
|
||||
|
||||
### 4. Dependencies
|
||||
|
||||
Added to `package.json`:
|
||||
```json
|
||||
{
|
||||
"mammoth": "^1.12.0", // Word document parsing
|
||||
"jszip": "^3.10.1", // PPTX ZIP extraction
|
||||
"pizzip": "^3.2.0" // Additional ZIP utilities
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Drag & Drop Support
|
||||
Users can drag PPTX/Word files directly into the chat composer. The files are automatically:
|
||||
1. Validated (type and size)
|
||||
2. Uploaded to the project folder
|
||||
3. Parsed to extract text and images
|
||||
4. Made available as `@-mentionable` files in the chat
|
||||
|
||||
### Extracted Content
|
||||
For each uploaded document:
|
||||
- **Original file** is preserved in the project folder
|
||||
- **Extracted text** is saved as `<filename>-extracted-text.txt`
|
||||
- **Embedded images** are saved individually with generated names
|
||||
- All files appear in the project file list and can be @-mentioned
|
||||
|
||||
### Error Handling
|
||||
- If parsing fails, the original file is still uploaded
|
||||
- Validation errors are logged but don't block upload
|
||||
- Parse errors include descriptive messages
|
||||
|
||||
## Usage Example
|
||||
|
||||
1. User drags `presentation.pptx` into chat
|
||||
2. System uploads and parses it
|
||||
3. Chat composer shows:
|
||||
- `presentation.pptx` (original)
|
||||
- `presentation-extracted-text.txt` (text content)
|
||||
- `extracted-image-xxx.png` (any embedded images)
|
||||
4. User can @-mention any of these files
|
||||
5. AI agent can read the extracted text to understand content
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Testing Steps
|
||||
|
||||
1. **Start the dev server:**
|
||||
```bash
|
||||
npm run dev:all
|
||||
```
|
||||
|
||||
2. **Test Word upload:**
|
||||
- Create or download a `.docx` file with text and images
|
||||
- Drag it into the chat composer
|
||||
- Verify extracted text file appears
|
||||
- Verify images are extracted
|
||||
|
||||
3. **Test PowerPoint upload:**
|
||||
- Create or download a `.pptx` file
|
||||
- Drag it into the chat composer
|
||||
- Verify slide text is extracted
|
||||
- Verify media images are extracted
|
||||
|
||||
4. **Test validation:**
|
||||
- Try uploading a file >10MB (should warn)
|
||||
- Try uploading unsupported format (should reject)
|
||||
|
||||
### Sample Test Files
|
||||
|
||||
Create test files:
|
||||
```bash
|
||||
# Word doc with sample content
|
||||
echo "Test document content" > test.txt
|
||||
# Convert to docx using any word processor
|
||||
|
||||
# Or download samples from:
|
||||
# - Microsoft Office templates
|
||||
# - Google Docs (export as DOCX/PPTX)
|
||||
```
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Why mammoth for Word?
|
||||
- Mature, well-maintained library
|
||||
- Handles both .doc and .docx
|
||||
- Good image extraction support
|
||||
- Converts to HTML for easier text extraction
|
||||
|
||||
### Why JSZip for PowerPoint?
|
||||
- PPTX files are ZIP archives containing XML
|
||||
- Direct XML parsing gives fine-grained control
|
||||
- No heavy dependencies
|
||||
- Can extract both text and media
|
||||
|
||||
### Alternative Approaches Considered
|
||||
|
||||
1. **officegen** - More for generation than parsing
|
||||
2. **pptxgenjs** - Similar, focused on creation
|
||||
3. **node-pptx** - Less mature
|
||||
4. **docx library** - Word-only, less features than mammoth
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Add progress indicators for large file uploads
|
||||
- [ ] Support PDF text extraction (pdf-parse library)
|
||||
- [ ] Preserve formatting metadata (fonts, colors, layouts)
|
||||
- [ ] Add preview thumbnails for uploaded documents
|
||||
- [ ] Support Excel files (.xlsx) for data extraction
|
||||
- [ ] Batch processing for multiple documents
|
||||
- [ ] OCR for scanned documents within PPTX/Word
|
||||
|
||||
## Related Issues
|
||||
|
||||
Closes nexu-io/open-design#42
|
||||
|
||||
## Screenshots
|
||||
|
||||
_Add screenshots of:_
|
||||
- File input accepting documents
|
||||
- Drag & drop UI with document files
|
||||
- Project file list showing extracted content
|
||||
- Chat with @-mentioned document content
|
||||
@@ -156,6 +156,11 @@ const EXT_MIME = {
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'.doc': 'application/msword',
|
||||
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'.ppt': 'application/vnd.ms-powerpoint',
|
||||
'.pdf': 'application/pdf',
|
||||
};
|
||||
|
||||
export function mimeFor(name) {
|
||||
@@ -179,5 +184,8 @@ export function kindFor(name) {
|
||||
if (['.js', '.mjs', '.cjs', '.ts', '.tsx', '.json', '.css'].includes(ext)) {
|
||||
return 'code';
|
||||
}
|
||||
if (['.docx', '.doc', '.pptx', '.ppt', '.pdf'].includes(ext)) {
|
||||
return 'document';
|
||||
}
|
||||
return 'binary';
|
||||
}
|
||||
|
||||
+70
-8
@@ -27,6 +27,11 @@ import {
|
||||
sanitizeName,
|
||||
writeProjectFile,
|
||||
} from './projects.js';
|
||||
import {
|
||||
isDocumentFile,
|
||||
parseDocument,
|
||||
validateDocument,
|
||||
} from './document-parser.js';
|
||||
import {
|
||||
deleteConversation,
|
||||
deleteProject as dbDeleteProject,
|
||||
@@ -659,6 +664,7 @@ export async function startServer({ port = 7456 } = {}) {
|
||||
// Files land flat in the project folder; the response carries the same
|
||||
// metadata as listFiles so the client can stage them as ChatAttachments
|
||||
// without a separate refetch.
|
||||
// For PPTX/Word files, automatically parse and extract content.
|
||||
app.post(
|
||||
'/api/projects/:id/upload',
|
||||
projectUpload.array('files', 12),
|
||||
@@ -666,16 +672,72 @@ export async function startServer({ port = 7456 } = {}) {
|
||||
try {
|
||||
const incoming = Array.isArray(req.files) ? req.files : [];
|
||||
const out = [];
|
||||
const projectDir = await ensureProject(PROJECTS_DIR, req.params.id);
|
||||
|
||||
for (const f of incoming) {
|
||||
try {
|
||||
const stat = await fs.promises.stat(f.path);
|
||||
out.push({
|
||||
name: f.filename,
|
||||
path: f.filename,
|
||||
size: stat.size,
|
||||
mtime: stat.mtimeMs,
|
||||
originalName: f.originalname,
|
||||
});
|
||||
// Check if this is a document file (PPTX/Word)
|
||||
if (isDocumentFile(f.originalname)) {
|
||||
const validation = validateDocument(f);
|
||||
if (!validation.valid) {
|
||||
console.warn(`Document validation failed: ${validation.errors.join(', ')}`);
|
||||
// Still add the file, but log the issue
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the document and extract content
|
||||
const { summary, extractedFiles } = await parseDocument(f.path, projectDir);
|
||||
|
||||
// Add the original file to output
|
||||
const stat = await fs.promises.stat(f.path);
|
||||
out.push({
|
||||
name: f.filename,
|
||||
path: f.filename,
|
||||
size: stat.size,
|
||||
mtime: stat.mtimeMs,
|
||||
originalName: f.originalname,
|
||||
documentParsed: true,
|
||||
documentSummary: summary,
|
||||
});
|
||||
|
||||
// Add all extracted files to output
|
||||
for (const extracted of extractedFiles) {
|
||||
const extractedPath = path.join(projectDir, extracted.name);
|
||||
const extractedStat = await fs.promises.stat(extractedPath);
|
||||
out.push({
|
||||
name: extracted.name,
|
||||
path: extracted.name,
|
||||
size: extractedStat.size,
|
||||
mtime: extractedStat.mtimeMs,
|
||||
originalName: extracted.name,
|
||||
extractedFrom: f.originalname,
|
||||
});
|
||||
}
|
||||
} catch (parseErr) {
|
||||
console.error(`Failed to parse document ${f.originalname}:`, parseErr);
|
||||
// Fall back to just uploading the file without parsing
|
||||
const stat = await fs.promises.stat(f.path);
|
||||
out.push({
|
||||
name: f.filename,
|
||||
path: f.filename,
|
||||
size: stat.size,
|
||||
mtime: stat.mtimeMs,
|
||||
originalName: f.originalname,
|
||||
documentParsed: false,
|
||||
parseError: parseErr.message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Regular file (non-document)
|
||||
const stat = await fs.promises.stat(f.path);
|
||||
out.push({
|
||||
name: f.filename,
|
||||
path: f.filename,
|
||||
size: stat.size,
|
||||
mtime: stat.mtimeMs,
|
||||
originalName: f.originalname,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// skip files that vanished mid-flight
|
||||
}
|
||||
|
||||
@@ -146,12 +146,22 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
||||
|
||||
async function uploadFiles(files: File[]) {
|
||||
if (files.length === 0) return;
|
||||
|
||||
// Check for documents
|
||||
const hasDocuments = files.some(f => looksLikeDocument(f.name));
|
||||
|
||||
const id = await ensureProject();
|
||||
if (!id) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const attachments = await uploadProjectFiles(id, files);
|
||||
setStaged((s) => [...s, ...attachments]);
|
||||
|
||||
// If documents were uploaded, log processing info
|
||||
if (hasDocuments) {
|
||||
const docCount = files.filter(f => looksLikeDocument(f.name)).length;
|
||||
console.log(`Processed ${docCount} document(s) - extracted text and images available in project files`);
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
@@ -290,6 +300,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,.pdf,.docx,.doc,.pptx,.ppt"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
@@ -485,6 +496,10 @@ function looksLikeImage(name: string): boolean {
|
||||
return /\.(png|jpe?g|gif|webp|svg|avif|bmp)$/i.test(name);
|
||||
}
|
||||
|
||||
function looksLikeDocument(name: string): boolean {
|
||||
return /\.(docx?|pptx?|pdf)$/i.test(name);
|
||||
}
|
||||
|
||||
function prettySize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
|
||||
Reference in New Issue
Block a user