5 Commits

15 changed files with 771 additions and 111 deletions

View File

@@ -79,7 +79,7 @@ In a world where document formatting tools are increasingly locked behind paywal
### 🎯 Core Capabilities ### 🎯 Core Capabilities
- **📄 Universal Markdown Support** - Drop in any `.md`, `.txt`, or `.markdown` file - **📄 Universal Input Support** - Drop in any `.md`, `.html`, `.txt`, or `.markdown` file - HTML content is auto-detected and converted
- **🎨 165+ Typography Styles** - Curated across 8 aesthetic categories - **🎨 165+ Typography Styles** - Curated across 8 aesthetic categories
- **📐 Multiple Paper Sizes** - A4 and Letter formats supported - **📐 Multiple Paper Sizes** - A4 and Letter formats supported
- **💾 Local Processing** - Your documents never leave your machine - **💾 Local Processing** - Your documents never leave your machine
@@ -167,7 +167,7 @@ The `.docx` files TypoGenie generates are also built with accessibility in mind:
| **Document Metadata** | Every exported DOCX includes `title`, `description`, and `creator` properties for assistive technology | | **Document Metadata** | Every exported DOCX includes `title`, `description`, and `creator` properties for assistive technology |
| **Heading Structure** | Semantic `HeadingLevel` preserved in both rendering modes (semantic and high-fidelity table layout) | | **Heading Structure** | Semantic `HeadingLevel` preserved in both rendering modes (semantic and high-fidelity table layout) |
| **Table Headers** | Rows containing `<th>` elements are marked with `tableHeader: true` so screen readers can announce column/row headers | | **Table Headers** | Rows containing `<th>` elements are marked with `tableHeader: true` so screen readers can announce column/row headers |
| **Image Placeholders** | `<img>` elements in Markdown are converted to italic `[Image: alt text]` placeholders rather than silently dropped | | **Image Embedding** | Images linked via URL are fetched and embedded in the exported DOCX, scaled to fit the page width. Falls back to italic `[Image: alt text]` placeholders if the image can't be fetched |
| **Contrast-Safe Colors** | All template text colors are validated and auto-corrected against their backgrounds before being applied to the document | | **Contrast-Safe Colors** | All template text colors are validated and auto-corrected against their backgrounds before being applied to the document |
### Runtime Contrast Validation ### Runtime Contrast Validation
@@ -228,7 +228,7 @@ This means all 165+ styles automatically meet WCAG AAA contrast requirements reg
**TypoGenie is fully portable** - no installation, no registry entries, no files scattered across your system. **TypoGenie is fully portable** - no installation, no registry entries, no files scattered across your system.
Just download and run: Just download and run:
- 🪟 **Windows**: `TypoGenie.exe` - Single executable, runs immediately - 🪟 **Windows**: [`TypoGenie-v1.2.1-Portable.zip`](https://git.lashman.live/lashman/typogenie/releases) - Extract and run, nothing else needed
**How it works:** **How it works:**
``` ```
@@ -294,7 +294,7 @@ npm run desktop:build
### Usage ### Usage
1. **📤 Upload** - Drag and drop your Markdown file (or click to browse) 1. **📤 Upload** - Drag and drop your Markdown, HTML, or text file (or click to browse)
2. **🎨 Select Style** - Browse the gallery and click any style for live preview 2. **🎨 Select Style** - Browse the gallery and click any style for live preview
3. **📐 Choose Paper Size** - A4 or Letter, depending on your needs 3. **📐 Choose Paper Size** - A4 or Letter, depending on your needs
4. **✨ Generate** - Watch the magic happen (with a satisfying loading animation) 4. **✨ Generate** - Watch the magic happen (with a satisfying loading animation)
@@ -327,7 +327,7 @@ npm run desktop:build
│ ▼ ▼ ▼ │ │ ▼ ▼ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │ │ ┌────────────────────────────────────────────────────────────┐ │
│ │ PROCESSING PIPELINE │ │ │ │ PROCESSING PIPELINE │ │
│ │ Markdown → marked parser → HTML → docx library → .docx │ │ │ │ Input → detect/convert → Markdown → marked → HTML → .docx │ │
│ └────────────────────────────────────────────────────────────┘ │ │ └────────────────────────────────────────────────────────────┘ │
│ │ │ │
└─────────────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────────────┘
@@ -428,8 +428,10 @@ typogenie/
│ │ ├── templateLoader.ts # Loads JSON templates via Rust │ │ ├── templateLoader.ts # Loads JSON templates via Rust
│ │ └── templateRenderer.ts # Generates CSS/DOCX with contrast validation │ │ └── templateRenderer.ts # Generates CSS/DOCX with contrast validation
│ ├── 📁 utils/ # Utilities │ ├── 📁 utils/ # Utilities
│ │ ├── contentDetector.ts # Auto-detects HTML vs Markdown vs plain text
│ │ ├── contrastUtils.ts # WCAG contrast ratio validation │ │ ├── contrastUtils.ts # WCAG contrast ratio validation
│ │ ── docxConverter.ts # Accessible DOCX generation │ │ ── docxConverter.ts # Accessible DOCX generation with image embedding
│ │ └── htmlToMarkdown.ts # HTML-to-Markdown conversion via Turndown
│ └── 📄 App.tsx # Main application component │ └── 📄 App.tsx # Main application component
├── 📁 src-tauri/ # Tauri desktop app ├── 📁 src-tauri/ # Tauri desktop app
│ ├── 📁 src/ # Rust backend code (filesystem access) │ ├── 📁 src/ # Rust backend code (filesystem access)
@@ -453,6 +455,7 @@ typogenie/
| **Animation** | Framer Motion (motion/react) | Transitions with reduced-motion support | | **Animation** | Framer Motion (motion/react) | Transitions with reduced-motion support |
| **Icons** | Lucide React | Beautiful, consistent iconography | | **Icons** | Lucide React | Beautiful, consistent iconography |
| **Markdown** | marked 12.0.0 | Local Markdown parsing | | **Markdown** | marked 12.0.0 | Local Markdown parsing |
| **HTML Import** | turndown 7.2.0 | HTML-to-Markdown conversion with content type detection |
| **Documents** | docx 8.5.0 | Client-side DOCX generation | | **Documents** | docx 8.5.0 | Client-side DOCX generation |
| **Desktop** | Tauri 2.0 | Native desktop apps (Rust + WebView) | | **Desktop** | Tauri 2.0 | Native desktop apps (Rust + WebView) |
| **Backend** | Rust 1.77+ | Systems programming for desktop shell | | **Backend** | Rust 1.77+ | Systems programming for desktop shell |
@@ -571,3 +574,5 @@ If TypoGenie brings value to your life, consider:
<img src="https://capsule-render.vercel.app/api?type=waving&color=0:a855f7,50:8b5cf6,100:6366f1&height=100&section=footer" alt="Footer" /> <img src="https://capsule-render.vercel.app/api?type=waving&color=0:a855f7,50:8b5cf6,100:6366f1&height=100&section=footer" alt="Footer" />
</div> </div>
<sub><i>This project is developed with the help of a locally-run LLM via opencode for scaffolding, planning, and routine code tasks. Architecture and design decisions are my own.</i></sub>

37
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "typogenie", "name": "typogenie",
"version": "1.0.0", "version": "1.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "typogenie", "name": "typogenie",
"version": "1.0.0", "version": "1.2.0",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0",
@@ -21,12 +21,15 @@
"marked": "12.0.0", "marked": "12.0.0",
"motion": "^12.29.2", "motion": "^12.29.2",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4",
"turndown": "^7.2.2",
"turndown-plugin-gfm": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@tauri-apps/cli": "^2.9.6", "@tauri-apps/cli": "^2.9.6",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/turndown": "^5.0.6",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"postcss": "^8.5.6", "postcss": "^8.5.6",
@@ -822,6 +825,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@mixmark-io/domino": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
"license": "BSD-2-Clause"
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.53", "version": "1.0.0-beta.53",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
@@ -1802,6 +1811,13 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/turndown": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz",
"integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz",
@@ -2890,6 +2906,21 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/turndown": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz",
"integrity": "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==",
"license": "MIT",
"dependencies": {
"@mixmark-io/domino": "^2.2.0"
}
},
"node_modules/turndown-plugin-gfm": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.2.tgz",
"integrity": "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==",
"license": "MIT"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.8.3", "version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",

View File

@@ -1,7 +1,7 @@
{ {
"name": "typogenie", "name": "typogenie",
"private": true, "private": true,
"version": "1.0.0", "version": "1.2.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -18,21 +18,24 @@
"@tauri-apps/plugin-dialog": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-fs": "^2.0.0", "@tauri-apps/plugin-fs": "^2.0.0",
"@tauri-apps/plugin-http": "^2.5.6", "@tauri-apps/plugin-http": "^2.5.6",
"@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-shell": "^2.3.4", "@tauri-apps/plugin-shell": "^2.3.4",
"@tauri-apps/plugin-store": "^2.4.2", "@tauri-apps/plugin-store": "^2.4.2",
"@tauri-apps/plugin-window-state": "^2.4.1", "@tauri-apps/plugin-window-state": "^2.4.1",
"@tauri-apps/plugin-opener": "^2.2.6",
"docx": "^8.5.0", "docx": "^8.5.0",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"marked": "12.0.0", "marked": "12.0.0",
"motion": "^12.29.2", "motion": "^12.29.2",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4",
"turndown": "^7.2.2",
"turndown-plugin-gfm": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@tauri-apps/cli": "^2.9.6", "@tauri-apps/cli": "^2.9.6",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/turndown": "^5.0.6",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"postcss": "^8.5.6", "postcss": "^8.5.6",

2
src-tauri/Cargo.lock generated
View File

@@ -4918,7 +4918,7 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]] [[package]]
name = "typogenie" name = "typogenie"
version = "1.0.0" version = "1.2.1"
dependencies = [ dependencies = [
"log", "log",
"opener", "opener",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "typogenie" name = "typogenie"
version = "1.0.0" version = "1.2.1"
description = "TypoGenie - Portable Markdown to Word document converter" description = "TypoGenie - Portable Markdown to Word document converter"
authors = ["TypoGenie Contributors"] authors = ["TypoGenie Contributors"]
license = "CC0-1.0" license = "CC0-1.0"

View File

@@ -27,25 +27,23 @@
"fs:allow-exe-write", "fs:allow-exe-write",
"store:default", "store:default",
"window-state:default", "window-state:default",
"http:default",
"http:allow-fetch",
"shell:default", "shell:default",
"shell:allow-open", "shell:allow-open",
"opener:default", "opener:default",
{ {
"identifier": "http:allow-fetch", "identifier": "http:default",
"allow": [ "allow": [
{ {
"url": "https://fonts.google.com/*" "url": "https://*"
}, },
{ {
"url": "https://github.com/*" "url": "http://*"
}, },
{ {
"url": "https://*.githubusercontent.com/*" "url": "https://*:*"
}, },
{ {
"url": "https://fonts.googleapis.com/*" "url": "http://*:*"
} }
] ]
}, },

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "TypoGenie", "productName": "TypoGenie",
"version": "1.0.0", "version": "1.2.1",
"identifier": "live.lashman.typogenie", "identifier": "live.lashman.typogenie",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
@@ -28,7 +28,7 @@
} }
], ],
"security": { "security": {
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost https://fonts.googleapis.com https://fonts.gstatic.com https://github.com https://raw.githubusercontent.com https://fonts.google.com; font-src 'self' https://fonts.gstatic.com data:; style-src 'self' 'unsafe-inline' data: blob: https://fonts.googleapis.com; img-src 'self' data: blob:; script-src 'self' 'unsafe-inline'; frame-src 'self' blob: about:;", "csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost https: http:; font-src 'self' https://fonts.gstatic.com data:; style-src 'self' 'unsafe-inline' data: blob: https://fonts.googleapis.com; img-src 'self' data: blob: https: http:; script-src 'self' 'unsafe-inline'; frame-src 'self' blob: about:;",
"dangerousDisableAssetCspModification": true "dangerousDisableAssetCspModification": true
} }
}, },

View File

@@ -10,7 +10,9 @@ import { useTemplates } from './hooks/useTemplates';
import { useDialog } from './hooks/useDialog'; import { useDialog } from './hooks/useDialog';
// @ts-ignore // @ts-ignore
import { parse } from 'marked'; import { parse } from 'marked';
import { Sparkles, Loader2, FileType, Keyboard, X, RefreshCw } from 'lucide-react'; import { Sparkles, Loader2, FileType, Keyboard, X, RefreshCw, AlertCircle } from 'lucide-react';
import { detectContentType } from './utils/contentDetector';
import { htmlToMarkdown } from './utils/htmlToMarkdown';
import { useKeyboardNavigation } from './hooks/useKeyboardNavigation'; import { useKeyboardNavigation } from './hooks/useKeyboardNavigation';
@@ -92,6 +94,7 @@ const App: React.FC = () => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showShortcuts, setShowShortcuts] = useState(false); const [showShortcuts, setShowShortcuts] = useState(false);
const [statusMessage, setStatusMessage] = useState(''); const [statusMessage, setStatusMessage] = useState('');
const [uploadError, setUploadError] = useState<string | null>(null);
const { uiZoom, setUiZoom, isLoaded } = useSettings(); const { uiZoom, setUiZoom, isLoaded } = useSettings();
const { templates, categories, isLoading: templatesLoading, error: templatesError, refresh, openFolder } = useTemplates(); const { templates, categories, isLoading: templatesLoading, error: templatesError, refresh, openFolder } = useTemplates();
@@ -135,9 +138,32 @@ const App: React.FC = () => {
const handleFileLoaded = (text: string, fileName: string = '') => { const handleFileLoaded = (text: string, fullFileName: string = '') => {
setContent(text); setUploadError(null);
setInputFileName(fileName);
const ext = fullFileName.includes('.')
? fullFileName.split('.').pop()?.toLowerCase() || ''
: '';
const displayName = fullFileName.replace(/\.[^/.]+$/, '') || fullFileName;
const detection = detectContentType(text, ext);
if (detection.error) {
setUploadError(detection.error);
return;
}
let processedContent = text;
if (detection.type === 'html') {
try {
processedContent = htmlToMarkdown(text, true);
} catch (err) {
console.error('HTML conversion failed:', err);
}
}
setContent(processedContent);
setInputFileName(displayName);
setAppState(AppState.CONFIG); setAppState(AppState.CONFIG);
}; };
@@ -174,6 +200,7 @@ const App: React.FC = () => {
setGeneratedHtml(''); setGeneratedHtml('');
setSelectedStyle(null); setSelectedStyle(null);
setInputFileName(''); setInputFileName('');
setUploadError(null);
}; };
const handleBackToConfig = () => { const handleBackToConfig = () => {
@@ -371,6 +398,17 @@ const App: React.FC = () => {
</motion.p> </motion.p>
</motion.div> </motion.div>
<FileUpload onFileLoaded={handleFileLoaded} /> <FileUpload onFileLoaded={handleFileLoaded} />
{uploadError && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mt-4 max-w-xl mx-auto p-4 bg-red-900/20 border border-red-800 rounded-lg flex items-center gap-3 text-red-200"
role="alert"
>
<AlertCircle size={20} className="flex-shrink-0" aria-hidden="true" />
<span>{uploadError}</span>
</motion.div>
)}
</motion.div> </motion.div>
)} )}

View File

@@ -23,7 +23,7 @@ export default function ExportOptionsModal({ isOpen, onClose, onExport }: Export
ref={dialogRef} ref={dialogRef}
onClick={handleBackdropClick} onClick={handleBackdropClick}
aria-labelledby="export-title" aria-labelledby="export-title"
className="fixed inset-0 z-50 p-4" className="fixed inset-0 z-50 p-4 m-0 w-full h-full border-none bg-black/50 flex items-center justify-center"
> >
<div <div
className="relative w-full max-w-2xl bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden border border-zinc-700" className="relative w-full max-w-2xl bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden border border-zinc-700"

View File

@@ -17,8 +17,9 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
const handleFile = (file: File) => { const handleFile = (file: File) => {
setError(null); setError(null);
if (!file.name.endsWith('.md') && !file.name.endsWith('.txt') && !file.name.endsWith('.markdown')) { const ext = file.name.split('.').pop()?.toLowerCase() || '';
setError('Please upload a Markdown (.md) or Text (.txt) file.'); if (!['md', 'txt', 'markdown', 'html', 'htm'].includes(ext)) {
setError('Please upload a Markdown (.md), HTML (.html), or Text (.txt) file.');
return; return;
} }
@@ -26,9 +27,7 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
reader.onload = (e) => { reader.onload = (e) => {
const text = e.target?.result; const text = e.target?.result;
if (typeof text === 'string') { if (typeof text === 'string') {
// Extract filename without extension onFileLoaded(text, file.name);
const fileName = file.name.replace(/\.[^/.]+$/, '');
onFileLoaded(text, fileName);
} }
}; };
reader.onerror = () => setError('Error reading file.'); reader.onerror = () => setError('Error reading file.');
@@ -132,7 +131,7 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
type="file" type="file"
className="hidden" className="hidden"
onChange={handleChange} onChange={handleChange}
accept=".md,.txt,.markdown" accept=".md,.txt,.markdown,.html,.htm"
aria-label="Select file" aria-label="Select file"
/> />
@@ -172,7 +171,7 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ delay: 0.7 }} transition={{ delay: 0.7 }}
> >
Markdown or Plain Text files Markdown, HTML, or Plain Text files
</motion.p> </motion.p>
<motion.p <motion.p
className="text-xs text-zinc-400 mt-2" className="text-xs text-zinc-400 mt-2"

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import { ArrowLeft, Download, FileText, CheckCircle2, ExternalLink, Loader2, ZoomIn, ZoomOut } from 'lucide-react'; import { ArrowLeft, Download, FileText, CheckCircle2, ExternalLink, Loader2, ZoomIn, ZoomOut, AlertTriangle } from 'lucide-react';
import { PaperSize } from '../types'; import { PaperSize } from '../types';
import { StyleOption } from '../types'; import { StyleOption } from '../types';
import { getPreviewCss } from '../services/templateRenderer'; import { getPreviewCss } from '../services/templateRenderer';
@@ -142,14 +142,17 @@ export const Preview: React.FC<PreviewProps> = ({
const [showExportModal, setShowExportModal] = useState(false); const [showExportModal, setShowExportModal] = useState(false);
const [focusedElement, setFocusedElement] = useState<'back' | 'fonts' | 'save'>('save'); const [focusedElement, setFocusedElement] = useState<'back' | 'fonts' | 'save'>('save');
const [exportError, setExportError] = useState<string | null>(null); const [exportError, setExportError] = useState<string | null>(null);
const [missingFonts, setMissingFonts] = useState<string[]>([]);
const [showFontWarning, setShowFontWarning] = useState(false);
// Get current style from templates // Get current style from templates
const style = templates.find(s => s.id === selectedStyleId) || templates[0] || null; const style = templates.find(s => s.id === selectedStyleId) || templates[0] || null;
// Extract used fonts for display // Extract used fonts for display (heading, body, and code)
const usedFonts = style ? Array.from(new Set([ const usedFonts = style ? Array.from(new Set([
style.typography?.fonts?.heading || style.wordConfig?.heading1?.font || 'Arial', style.typography?.fonts?.heading || style.wordConfig?.heading1?.font || 'Arial',
style.typography?.fonts?.body || style.wordConfig?.body?.font || 'Arial' style.typography?.fonts?.body || style.wordConfig?.body?.font || 'Arial',
style.typography?.fonts?.code || 'JetBrains Mono'
])).filter(Boolean) : []; ])).filter(Boolean) : [];
useKeyboardNavigation({ useKeyboardNavigation({
@@ -165,6 +168,24 @@ export const Preview: React.FC<PreviewProps> = ({
}, []); }, []);
const handleSave = async () => { const handleSave = async () => {
// Check if required fonts are installed using Local Font Access API
let missing: string[] = [];
try {
if ('queryLocalFonts' in window) {
const localFonts = await (window as any).queryLocalFonts();
const installed = new Set(localFonts.map((f: any) => f.family));
missing = usedFonts.filter(font => !installed.has(font));
}
} catch {
// Permission denied or API unavailable - skip check
}
if (missing.length > 0) {
setMissingFonts(missing);
setShowFontWarning(true);
return;
}
setShowExportModal(true); setShowExportModal(true);
}; };
@@ -252,7 +273,6 @@ export const Preview: React.FC<PreviewProps> = ({
// Track blob URL for cleanup // Track blob URL for cleanup
const blobUrlRef = useRef<string | null>(null); const blobUrlRef = useRef<string | null>(null);
// Render preview whenever dependencies change // Render preview whenever dependencies change
useEffect(() => { useEffect(() => {
if (!iframeRef.current || !style) return; if (!iframeRef.current || !style) return;
@@ -301,7 +321,7 @@ export const Preview: React.FC<PreviewProps> = ({
`.page {`, `.page {`,
` width: ${paperSize === 'A4' ? '210mm' : '8.5in'};`, ` width: ${paperSize === 'A4' ? '210mm' : '8.5in'};`,
` min-height: ${paperSize === 'A4' ? '297mm' : '11in'};`, ` min-height: ${paperSize === 'A4' ? '297mm' : '11in'};`,
` padding: 25mm;`, ` padding: ${style.page?.margins ? `${style.page.margins.top}pt ${style.page.margins.right}pt ${style.page.margins.bottom}pt ${style.page.margins.left}pt` : '25mm'};`,
` box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.4);`, ` box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.4);`,
` box-sizing: border-box;`, ` box-sizing: border-box;`,
` margin: 0 auto;`, ` margin: 0 auto;`,
@@ -440,6 +460,48 @@ export const Preview: React.FC<PreviewProps> = ({
<button onClick={() => setExportError(null)} className="ml-3 text-red-400 hover:text-white" aria-label="Dismiss error">&#10005;</button> <button onClick={() => setExportError(null)} className="ml-3 text-red-400 hover:text-white" aria-label="Dismiss error">&#10005;</button>
</div> </div>
)} )}
{showFontWarning && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="bg-zinc-900 border border-zinc-700 rounded-2xl p-6 max-w-md w-full shadow-2xl mx-4"
>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-amber-500/10 rounded-lg text-amber-400">
<AlertTriangle size={20} />
</div>
<h3 className="text-lg font-bold text-white">Missing Fonts</h3>
</div>
<p className="text-zinc-300 text-sm mb-3">
The following fonts are not installed on your system:
</p>
<ul className="space-y-1 mb-4">
{missingFonts.map(font => (
<li key={font} className="text-amber-300 text-sm font-medium">- {font}</li>
))}
</ul>
<p className="text-zinc-400 text-xs mb-6">
Download and install them using the font buttons at the top of the page before opening the exported document.
</p>
<div className="flex gap-3">
<button
onClick={() => { setShowFontWarning(false); setShowExportModal(true); }}
className="flex-1 px-4 py-2.5 text-sm font-medium text-zinc-300 bg-zinc-800 border border-zinc-700 rounded-lg hover:bg-zinc-700 transition-colors"
>
Export Anyway
</button>
<button
onClick={() => setShowFontWarning(false)}
className="flex-1 px-4 py-2.5 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-500 transition-colors"
>
Cancel
</button>
</div>
</motion.div>
</div>
)}
</motion.div> </motion.div>
); );
}; };

View File

@@ -229,6 +229,22 @@ export const StyleSelector: React.FC<StyleSelectorProps> = ({
<div class="page"> <div class="page">
${SAMPLE_CONTENT} ${SAMPLE_CONTENT}
</div> </div>
<script>
function fitPage() {
var page = document.querySelector('.page');
if (!page) return;
var available = window.innerWidth;
var needed = page.offsetWidth + 80;
if (needed > available) {
document.body.style.zoom = (available / needed).toFixed(3);
} else {
document.body.style.zoom = '1';
}
}
window.addEventListener('load', fitPage);
window.addEventListener('resize', fitPage);
setTimeout(fitPage, 100);
</script>
</body> </body>
</html> </html>
`; `;
@@ -384,6 +400,7 @@ export const StyleSelector: React.FC<StyleSelectorProps> = ({
role="listbox" role="listbox"
aria-label="Typography styles" aria-label="Typography styles"
aria-activedescendant={selectedStyle ? `style-${selectedStyle}` : undefined} aria-activedescendant={selectedStyle ? `style-${selectedStyle}` : undefined}
className="space-y-2"
> >
{filteredStyles.length === 0 ? ( {filteredStyles.length === 0 ? (
<div className="text-center py-8 text-zinc-400"> <div className="text-center py-8 text-zinc-400">

View File

@@ -0,0 +1,136 @@
export type ContentType = 'html' | 'markdown' | 'text';
export interface DetectionResult {
type: ContentType;
error?: string;
detectedFormat?: string;
}
const BINARY_SIGNATURES: [string, string][] = [
['%PDF', 'PDF document'],
['PK', 'Word document or ZIP archive'],
['\x89PNG', 'PNG image'],
['\xFF\xD8', 'JPEG image'],
['GIF8', 'GIF image'],
['RIFF', 'media file'],
['Rar!', 'RAR archive'],
];
function detectBinaryFormat(content: string): string | null {
if (content.includes('\0')) {
for (const [sig, name] of BINARY_SIGNATURES) {
if (content.startsWith(sig)) return name;
}
return 'binary file';
}
let nonPrintable = 0;
const len = Math.min(content.length, 512);
for (let i = 0; i < len; i++) {
const code = content.charCodeAt(i);
if (code < 32 && code !== 9 && code !== 10 && code !== 13) {
nonPrintable++;
}
}
return nonPrintable / len > 0.1 ? 'binary file' : null;
}
function stripCodeBlocks(content: string): string {
return content.replace(/```[\s\S]*?```/g, '');
}
const STRUCTURAL_TAG_RE = /<(div|p|table|tr|td|th|thead|tbody|tfoot|ul|ol|li|h[1-6]|section|article|header|footer|nav|main|aside|form|blockquote|pre|dl|dt|dd|figure|figcaption|hr)\b[^>]*>/gi;
const INLINE_TAG_RE = /<(span|b|i|u|strong|em|a|img|br|code|sub|sup|small|mark|del|ins|s|abbr)\b[^>]*>/gi;
function countStructuralTags(content: string): number {
return (content.match(STRUCTURAL_TAG_RE) || []).length;
}
function countInlineTags(content: string): number {
return (content.match(INLINE_TAG_RE) || []).length;
}
function countMarkdownSyntax(content: string): number {
let score = 0;
const lines = content.split('\n');
for (const line of lines) {
const t = line.trim();
if (/^#{1,6}\s/.test(t)) score += 3;
if (/^[-*+]\s/.test(t)) score += 2;
if (/^\d+\.\s/.test(t)) score += 2;
if (/^>\s/.test(t)) score += 2;
if (/^(---|\*\*\*|___)$/.test(t)) score += 2;
if (/^```/.test(t)) score += 3;
}
const sample = content.substring(0, 5000);
score += (sample.match(/\*\*[^*]+\*\*/g) || []).length;
score += (sample.match(/\[([^\]]+)\]\(([^)]+)\)/g) || []).length * 2;
score += (sample.match(/!\[([^\]]*)\]\(([^)]+)\)/g) || []).length * 2;
return score;
}
export function detectContentType(content: string, extension: string): DetectionResult {
if (!content || !content.trim()) {
return { type: 'text' };
}
const binaryFormat = detectBinaryFormat(content);
if (binaryFormat) {
return {
type: 'text',
error: `This appears to be a ${binaryFormat}. TypoGenie accepts Markdown, HTML, and plain text files.`,
detectedFormat: binaryFormat,
};
}
// Full HTML document detection
const trimmed = content.trimStart().toLowerCase();
if (trimmed.startsWith('<!doctype') || trimmed.startsWith('<html')) {
return { type: 'html' };
}
const mdScore = countMarkdownSyntax(content);
// Count HTML tags on content with code blocks stripped to avoid false positives
const stripped = stripCodeBlocks(content);
const structural = countStructuralTags(stripped);
const inline = countInlineTags(stripped);
// Both signals strong - likely markdown with HTML examples
if (structural >= 3 && mdScore >= 5) {
return { type: 'markdown' };
}
// Strong HTML signal
if (structural >= 3) {
return { type: 'html' };
}
// Moderate HTML: few structural tags but heavy inline tags (Blogger/Google Docs style)
if (structural >= 1 && inline >= 10) {
return { type: 'html' };
}
// Strong markdown signal
if (mdScore >= 3) {
return { type: 'markdown' };
}
// Weak HTML with no markdown at all
if (structural >= 1 && mdScore === 0) {
return { type: 'html' };
}
// Extension as tiebreaker
if (extension === 'html' || extension === 'htm') {
return { type: 'html' };
}
if (extension === 'md' || extension === 'markdown') {
return { type: 'markdown' };
}
return { type: 'text' };
}

View File

@@ -3,10 +3,11 @@ import {
Document, Paragraph, TextRun, AlignmentType, HeadingLevel, BorderStyle, Document, Paragraph, TextRun, AlignmentType, HeadingLevel, BorderStyle,
UnderlineType, ShadingType, LevelFormat, UnderlineType, ShadingType, LevelFormat,
Packer, Table, TableCell, TableRow, WidthType, VerticalAlign, Packer, Table, TableCell, TableRow, WidthType, VerticalAlign,
ExternalHyperlink, TableBorders ExternalHyperlink, TableBorders, ImageRun
} from 'docx'; } from 'docx';
import { DocxStyleConfig, PaperSize, TemplateElementStyle } from '../types'; import { DocxStyleConfig, PaperSize, TemplateElementStyle } from '../types';
import { resolveColor, resolveFont } from '../services/templateRenderer'; import { resolveColor, resolveFont } from '../services/templateRenderer';
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
const pt = (points: number) => points * 2; const pt = (points: number) => points * 2;
const inchesToTwips = (inches: number) => Math.round(inches * 1440); const inchesToTwips = (inches: number) => Math.round(inches * 1440);
@@ -185,6 +186,72 @@ export const generateDocxDocument = async (
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, 'text/html'); const doc = parser.parseFromString(htmlContent, 'text/html');
// Pre-fetch all images for embedding
const imageCache = new Map<string, { data: Uint8Array; width: number; height: number }>();
const imgElements = doc.querySelectorAll('img');
for (const img of Array.from(imgElements)) {
const src = img.getAttribute('src');
if (!src || src.startsWith('data:')) continue;
// Get display dimensions from HTML attributes (prefer width/height over data-original-*)
const htmlW = parseInt(img.getAttribute('width') || '0');
const htmlH = parseInt(img.getAttribute('height') || '0');
try {
// Step 1: Fetch the image bytes
let data: Uint8Array | null = null;
// Try Tauri HTTP plugin
try {
const resp = await tauriFetch(src, { method: 'GET' });
if (resp.ok) {
data = new Uint8Array(await resp.arrayBuffer());
}
} catch (e1) {
console.warn('tauriFetch failed, trying standard fetch:', e1);
}
// Fallback to standard fetch
if (!data) {
try {
const resp = await globalThis.fetch(src, { mode: 'no-cors' });
// no-cors gives opaque response, try cors mode
const resp2 = await globalThis.fetch(src);
if (resp2.ok) {
data = new Uint8Array(await resp2.arrayBuffer());
}
} catch (e2) {
console.warn('Standard fetch also failed:', e2);
}
}
if (!data || data.length === 0) {
console.warn('No image data received for:', src);
continue;
}
// Step 2: Determine dimensions
let width = htmlW;
let height = htmlH;
if (!width || !height) {
try {
const bitmap = await createImageBitmap(new Blob([data]));
width = bitmap.width;
height = bitmap.height;
bitmap.close();
} catch {
width = width || 600;
height = height || 400;
}
}
imageCache.set(src, { data, width, height });
console.log('Image cached:', src.substring(0, 60), width, 'x', height, data.length, 'bytes');
} catch (err) {
console.warn('Image embed failed for:', src, err);
}
}
const children: (Paragraph | Table)[] = []; const children: (Paragraph | Table)[] = [];
// Track separate ordered lists for independent numbering // Track separate ordered lists for independent numbering
@@ -284,6 +351,26 @@ export const generateDocxDocument = async (
return elementConfig?.allCaps || false; return elementConfig?.allCaps || false;
}; };
// Create an ImageRun from a cached image, scaled to fit the page
// Note: docx library transformation uses PIXELS (it converts to EMU internally)
const createInlineImageRun = (src: string): ImageRun | null => {
const cached = imageCache.get(src);
if (!cached) return null;
let width = cached.width;
let height = cached.height;
// Max width in pixels at 96 DPI
const pageWidthTwips = paperSize === 'A4' ? mmToTwips(210) : inchesToTwips(8.5);
const leftMargin = (options.page?.margins?.left || 72) * 20;
const rightMargin = (options.page?.margins?.right || 72) * 20;
const maxWidthPx = ((pageWidthTwips - leftMargin - rightMargin) / 1440) * 96;
if (width > maxWidthPx) {
const scale = maxWidthPx / width;
width = Math.round(maxWidthPx);
height = Math.round(height * scale);
}
return new ImageRun({ data: cached.data, transformation: { width, height } });
};
// Process text runs with support for links and formatting // Process text runs with support for links and formatting
const processTextRuns = (element: HTMLElement, baseFormatting: any = {}, elementType?: string): (TextRun | ExternalHyperlink)[] => { const processTextRuns = (element: HTMLElement, baseFormatting: any = {}, elementType?: string): (TextRun | ExternalHyperlink)[] => {
const runs: (TextRun | ExternalHyperlink)[] = []; const runs: (TextRun | ExternalHyperlink)[] = [];
@@ -360,6 +447,18 @@ export const generateDocxDocument = async (
} else if (node.nodeType === Node.ELEMENT_NODE) { } else if (node.nodeType === Node.ELEMENT_NODE) {
const childEl = node as HTMLElement; const childEl = node as HTMLElement;
const childTag = childEl.tagName.toLowerCase(); const childTag = childEl.tagName.toLowerCase();
if (childTag === 'br') {
linkRuns.push(new TextRun({ break: 1 }) as any);
return;
}
if (childTag === 'img') {
const imgSrc = childEl.getAttribute('src');
if (imgSrc) {
const imgRun = createInlineImageRun(imgSrc);
if (imgRun) linkRuns.push(imgRun as any);
}
return;
}
const childFmt = { ...fmt }; const childFmt = { ...fmt };
if (childTag === 'strong' || childTag === 'b') childFmt.bold = true; if (childTag === 'strong' || childTag === 'b') childFmt.bold = true;
if (childTag === 'em' || childTag === 'i') childFmt.italics = true; if (childTag === 'em' || childTag === 'i') childFmt.italics = true;
@@ -471,9 +570,15 @@ export const generateDocxDocument = async (
if (tag === 's' || tag === 'strike') fmt.strike = true; if (tag === 's' || tag === 'strike') fmt.strike = true;
if (tag === 'sub') fmt.subScript = true; if (tag === 'sub') fmt.subScript = true;
if (tag === 'sup') fmt.superScript = true; if (tag === 'sup') fmt.superScript = true;
if (tag === 'br') {
runs.push(new TextRun({ break: 1 }) as any);
return;
}
if (tag === 'code') { if (tag === 'code') {
fmt.font = codeFontResolved; fmt.font = codeFontResolved;
fmt.color = codeTextColor; fmt.color = codeTextColor;
if (elements?.code?.size) fmt.size = pt(elements.code.size);
if (codeBgColor) fmt.shading = { fill: codeBgColor, type: ShadingType.CLEAR };
} }
// Handle links // Handle links
@@ -498,6 +603,18 @@ export const generateDocxDocument = async (
} else if (node.nodeType === Node.ELEMENT_NODE) { } else if (node.nodeType === Node.ELEMENT_NODE) {
const childEl = node as HTMLElement; const childEl = node as HTMLElement;
const childTag = childEl.tagName.toLowerCase(); const childTag = childEl.tagName.toLowerCase();
if (childTag === 'br') {
linkRuns.push(new TextRun({ break: 1 }) as any);
return;
}
if (childTag === 'img') {
const imgSrc = childEl.getAttribute('src');
if (imgSrc) {
const imgRun = createInlineImageRun(imgSrc);
if (imgRun) linkRuns.push(imgRun as any);
}
return;
}
const childFmt = { ...fmt }; const childFmt = { ...fmt };
if (childTag === 'strong' || childTag === 'b') childFmt.bold = true; if (childTag === 'strong' || childTag === 'b') childFmt.bold = true;
if (childTag === 'em' || childTag === 'i') childFmt.italics = true; if (childTag === 'em' || childTag === 'i') childFmt.italics = true;
@@ -522,6 +639,16 @@ export const generateDocxDocument = async (
} }
} }
// Handle standalone images in text runs
if (tag === 'img') {
const imgSrc = el.getAttribute('src');
if (imgSrc) {
const imgRun = createInlineImageRun(imgSrc);
if (imgRun) runs.push(imgRun as any);
}
return;
}
const style = el.getAttribute('style') || ''; const style = el.getAttribute('style') || '';
const colorMatch = style.match(/color:\s*#?([a-fA-F0-9]{6})/); const colorMatch = style.match(/color:\s*#?([a-fA-F0-9]{6})/);
if (colorMatch) fmt.color = colorMatch[1]; if (colorMatch) fmt.color = colorMatch[1];
@@ -556,11 +683,16 @@ export const generateDocxDocument = async (
spacing: { spacing: {
before: 0, before: 0,
after: 0, after: 0,
line: Math.round((cfg?.spacing?.line || 1.2) * 240), line: Math.round(Math.max(cfg?.spacing?.line || 1.2, 1.5) * 240),
} }
})); }));
const cellBorders: any = {}; const cellBorders: any = {
top: { style: BorderStyle.NIL, size: 0, color: '000000' },
bottom: { style: BorderStyle.NIL, size: 0, color: '000000' },
left: { style: BorderStyle.NIL, size: 0, color: '000000' },
right: { style: BorderStyle.NIL, size: 0, color: '000000' }
};
if (cfg?.border) { if (cfg?.border) {
const b = { color: resolveColorToHex(cfg.border.color) || '000000', style: mapBorderStyle(cfg.border.style), size: cfg.border.width * 8 }; const b = { color: resolveColorToHex(cfg.border.color) || '000000', style: mapBorderStyle(cfg.border.style), size: cfg.border.width * 8 };
cellBorders.top = b; cellBorders.top = b;
@@ -696,20 +828,27 @@ export const generateDocxDocument = async (
console.log('TABLE DOCX: Processing table'); console.log('TABLE DOCX: Processing table');
// Get table-level border config // Get table-level border config - check both generic and per-side borders
const tableBorderConfig = elements?.table?.border; const tblCfg = elements?.table;
const tableBorderColor = resolveColorToHex(tableBorderConfig?.color) || (isDark ? '444444' : 'CCCCCC'); const defaultBorderColor = isDark ? '444444' : 'CCCCCC';
const tableBorderWidth = tableBorderConfig?.width || 1; const makeBorder = (cfg: any, fallbackColor: string) => ({
const tableBorderStyle = mapBorderStyle(tableBorderConfig?.style || 'single'); color: resolveColorToHex(cfg?.color) || fallbackColor,
size: (cfg?.width || 1) * 8,
style: mapBorderStyle(cfg?.style || 'single')
});
// Create table-level borders (outer border only by default) const noBorder = { style: BorderStyle.NONE, size: 0, color: 'auto' };
const htmlBorderAttr = tableEl.getAttribute('border');
const hasHtmlBorder = htmlBorderAttr && parseInt(htmlBorderAttr) > 0;
const genericBorder = tblCfg?.border ? makeBorder(tblCfg.border, defaultBorderColor) : null;
const tableBorders = { const tableBorders = {
top: { color: tableBorderColor, size: tableBorderWidth * 8, style: tableBorderStyle }, top: tblCfg?.borderTop ? makeBorder(tblCfg.borderTop, defaultBorderColor) : (genericBorder || (hasHtmlBorder ? { color: defaultBorderColor, size: 4, style: BorderStyle.SINGLE } : noBorder)),
bottom: { color: tableBorderColor, size: tableBorderWidth * 8, style: tableBorderStyle }, bottom: tblCfg?.borderBottom ? makeBorder(tblCfg.borderBottom, defaultBorderColor) : (genericBorder || (hasHtmlBorder ? { color: defaultBorderColor, size: 4, style: BorderStyle.SINGLE } : noBorder)),
left: { color: tableBorderColor, size: tableBorderWidth * 8, style: tableBorderStyle }, left: tblCfg?.borderLeft ? makeBorder(tblCfg.borderLeft, defaultBorderColor) : (genericBorder || (hasHtmlBorder ? { color: defaultBorderColor, size: 4, style: BorderStyle.SINGLE } : noBorder)),
right: { color: tableBorderColor, size: tableBorderWidth * 8, style: tableBorderStyle }, right: tblCfg?.borderRight ? makeBorder(tblCfg.borderRight, defaultBorderColor) : (genericBorder || (hasHtmlBorder ? { color: defaultBorderColor, size: 4, style: BorderStyle.SINGLE } : noBorder)),
insideHorizontal: { style: BorderStyle.NIL, size: 0 }, insideHorizontal: elements?.th?.borderBottom ? makeBorder(elements.th.borderBottom, defaultBorderColor) :
insideVertical: { style: BorderStyle.NIL, size: 0 } (hasHtmlBorder ? { color: defaultBorderColor, size: 4, style: BorderStyle.SINGLE } : noBorder),
insideVertical: hasHtmlBorder ? { color: defaultBorderColor, size: 4, style: BorderStyle.SINGLE } : noBorder
}; };
for (const rowEl of Array.from(tableEl.querySelectorAll('tr'))) { for (const rowEl of Array.from(tableEl.querySelectorAll('tr'))) {
@@ -737,8 +876,11 @@ export const generateDocxDocument = async (
bold: isHeader || undefined bold: isHeader || undefined
}); });
// Get background from config // Get background: HTML bgcolor attribute takes priority, then template config
const cellBg = resolveColorToHex(cellConfig?.background); const htmlBgColor = cell.getAttribute('bgcolor');
const cellBg = htmlBgColor
? formatColor(htmlBgColor.replace('#', ''))
: resolveColorToHex(cellConfig?.background);
console.log(`TABLE CELL DOCX [${isHeader ? 'TH' : 'TD'}]:`, { console.log(`TABLE CELL DOCX [${isHeader ? 'TH' : 'TD'}]:`, {
text: cell.textContent?.substring(0, 30) + (cell.textContent && cell.textContent.length > 30 ? '...' : ''), text: cell.textContent?.substring(0, 30) + (cell.textContent && cell.textContent.length > 30 ? '...' : ''),
@@ -749,7 +891,8 @@ export const generateDocxDocument = async (
bold: isHeader || undefined bold: isHeader || undefined
}); });
// Resolve cell-specific borders from template // Resolve cell-specific borders from template config only
// (HTML border is handled at table level via insideH/insideV to avoid overriding thick outer borders)
const cellBorders: any = {}; const cellBorders: any = {};
if (cellConfig?.border) { if (cellConfig?.border) {
const b = { color: resolveColorToHex(cellConfig.border.color) || '000000', style: mapBorderStyle(cellConfig.border.style), size: (cellConfig.border.width || 1) * 8 }; const b = { color: resolveColorToHex(cellConfig.border.color) || '000000', style: mapBorderStyle(cellConfig.border.style), size: (cellConfig.border.width || 1) * 8 };
@@ -766,10 +909,10 @@ export const generateDocxDocument = async (
cells.push(new TableCell({ cells.push(new TableCell({
children: [new Paragraph({ children: [new Paragraph({
children: cellRuns.length > 0 ? cellRuns : [new TextRun({ text: cell.textContent || '' })], children: cellRuns.length > 0 ? cellRuns : [new TextRun({ text: cell.textContent || '' })],
alignment: isHeader ? AlignmentType.CENTER : mapAlignment(cellConfig?.align), alignment: mapAlignment(cellConfig?.align || cell.getAttribute('align') || (cell.getAttribute('style')?.match(/text-align:\s*(\w+)/)?.[1]) || undefined),
spacing: { spacing: {
after: 0, after: 0,
line: Math.round((body.spacing?.line || 1.2) * 240) line: Math.round(Math.max(elements?.table?.spacing?.line || body.spacing?.line || 1.2, 1.5) * 240)
} }
})], })],
shading: cellBg ? { fill: cellBg, type: ShadingType.CLEAR } : undefined, shading: cellBg ? { fill: cellBg, type: ShadingType.CLEAR } : undefined,
@@ -911,8 +1054,8 @@ export const generateDocxDocument = async (
} }
} }
const liSpacingBefore = (elements?.li?.spacing?.before || 4) * 20; const liSpacingBefore = (elements?.li?.spacing?.before ?? 4) * 20;
const liSpacingAfter = (elements?.li?.spacing?.after || 4) * 20; const liSpacingAfter = (elements?.li?.spacing?.after ?? 4) * 20;
const liLineHeight = (elements?.li?.spacing?.line || body.spacing?.line || 1.2) * 240; const liLineHeight = (elements?.li?.spacing?.line || body.spacing?.line || 1.2) * 240;
// Log the actual text runs and their styling // Log the actual text runs and their styling
@@ -1115,9 +1258,12 @@ export const generateDocxDocument = async (
return results; return results;
} }
// Tables // Tables - with spacing paragraphs before/after
if (tag === 'table') { if (tag === 'table') {
const tblSpacing = elements?.table?.spacing;
results.push(new Paragraph({ spacing: { before: (tblSpacing?.before || 18) * 20, after: 0 }, children: [] }));
results.push(processTable(el)); results.push(processTable(el));
results.push(new Paragraph({ spacing: { before: 0, after: (tblSpacing?.after || 18) * 20 }, children: [] }));
return results; return results;
} }
@@ -1145,66 +1291,131 @@ export const generateDocxDocument = async (
}); });
} }
// Center paragraphs that only contain an image
const isImageOnly = el.querySelector('img') !== null && !el.textContent?.trim();
const pSpacing = elements?.p?.spacing || body.spacing;
// When template spacing is 0, CSS generator skips the margin, so browser default 1em applies
const pAfter = (pSpacing?.after || body.size) * 20;
results.push(new Paragraph({ results.push(new Paragraph({
children: runs.length > 0 ? runs : [new TextRun({ text: el.textContent || '' })], children: runs.length > 0 ? runs : [new TextRun({ text: el.textContent || '' })],
alignment: mapAlignment(body.align), alignment: isImageOnly ? AlignmentType.CENTER : mapAlignment(elements?.p?.align || body.align),
indent: isImageOnly ? undefined : (elements?.p?.indent ? { firstLine: elements.p.indent * 20 } : undefined),
spacing: { spacing: {
before: (body.spacing?.before || 0) * 20, before: (pSpacing?.before || 0) * 20,
after: (body.spacing?.after || 0) * 20, after: pAfter,
line: Math.round((body.spacing?.line || 1.2) * 240) line: Math.round((pSpacing?.line || 1.2) * 240)
}, },
shading: bgMatch ? { fill: formatColor(resolveColorToHex(bgMatch[1])), type: ShadingType.CLEAR } : undefined shading: bgMatch ? { fill: formatColor(resolveColorToHex(bgMatch[1])), type: ShadingType.CLEAR } : undefined
})); }));
return results; return results;
} }
// Blockquotes // Blockquotes - process each inner <p> as a separate paragraph with blockquote styling
// Word groups adjacent paragraphs with identical borders, showing top/bottom only on outer edges
if (tag === 'blockquote') { if (tag === 'blockquote') {
const bqConfig = elements?.blockquote; const bqConfig = elements?.blockquote;
const runs = processTextRuns(el, { const bqFont = bqConfig?.font ? resolveFont(bqConfig.font, fonts || {}) : body.font;
font: bqConfig?.font ? resolveFont(bqConfig.font, fonts || {}) : body.font, const bqSize = pt(bqConfig?.size || body.size);
size: pt(bqConfig?.size || body.size), const bqColor = formatColor(resolveColorToHex(bqConfig?.color || body.color));
color: formatColor(resolveColorToHex(bqConfig?.color || body.color)), const bqFmt = { font: bqFont, size: bqSize, color: bqColor, italics: bqConfig?.italic !== false };
italics: true
console.log('DOCX BLOCKQUOTE:', {
font: bqFont, size: bqSize, color: bqColor, childCount: el.children.length
}); });
const borderColor = resolveColorToHex(bqConfig?.borderLeft?.color) || accentColor; const bqBorder: any = {};
const borderWidth = bqConfig?.borderLeft?.width || 3; if (bqConfig?.border) {
const b = { color: resolveColorToHex(bqConfig.border.color) || accentColor, space: 6, style: mapBorderStyle(bqConfig.border.style), size: (bqConfig.border.width || 1) * 8 };
const debugKey = 'blockquote-debug'; bqBorder.top = b; bqBorder.bottom = b; bqBorder.left = b; bqBorder.right = b;
if (!visitedTags.has(debugKey)) {
visitedTags.add(debugKey);
console.log('DOCX BLOCKQUOTE CONFIG:', {
font: bqConfig?.font,
size: bqConfig?.size,
color: formatColor(resolveColorToHex(bqConfig?.color)),
border: { color: borderColor, width: borderWidth },
background: bqConfig?.background
});
} }
if (bqConfig?.borderTop) bqBorder.top = { color: resolveColorToHex(bqConfig.borderTop.color) || accentColor, space: 6, style: mapBorderStyle(bqConfig.borderTop.style), size: (bqConfig.borderTop.width || 1) * 8 };
if (bqConfig?.borderBottom) bqBorder.bottom = { color: resolveColorToHex(bqConfig.borderBottom.color) || accentColor, space: 6, style: mapBorderStyle(bqConfig.borderBottom.style), size: (bqConfig.borderBottom.width || 1) * 8 };
if (bqConfig?.borderLeft) bqBorder.left = { color: resolveColorToHex(bqConfig.borderLeft.color) || accentColor, space: 10, style: mapBorderStyle(bqConfig.borderLeft.style), size: (bqConfig.borderLeft.width || 1) * 8 };
if (bqConfig?.borderRight) bqBorder.right = { color: resolveColorToHex(bqConfig.borderRight.color) || accentColor, space: 6, style: mapBorderStyle(bqConfig.borderRight.style), size: (bqConfig.borderRight.width || 1) * 8 };
results.push(new Paragraph({ const bqLine = Math.max(bqConfig?.spacing?.line || body.spacing?.line || 1.2, 1.5);
const bqBorderObj = Object.keys(bqBorder).length > 0 ? bqBorder : undefined;
const bqShading = bqConfig?.background ? { fill: resolveColorToHex(bqConfig.background), type: ShadingType.CLEAR } : undefined;
const bqSpacing = {
before: 0,
after: body.size * 20, // 1em gap between inner paragraphs (matches browser default)
line: Math.round(bqLine * 240)
};
// Process children - each <p> becomes its own paragraph with blockquote styling
const childEls = Array.from(el.children);
const makeBqParagraph = (runs: any[], isFirst: boolean, isLast: boolean, align?: any) => new Paragraph({
children: runs, children: runs,
indent: { left: 720 }, alignment: align || mapAlignment(bqConfig?.align),
border: { left: { color: borderColor, space: 10, style: BorderStyle.SINGLE, size: borderWidth * 8 } }, indent: bqConfig?.indent ? { left: bqConfig.indent * 20 } : undefined,
shading: bqConfig?.background ? { fill: resolveColorToHex(bqConfig.background), type: ShadingType.CLEAR } : (isDark ? undefined : { fill: 'F8F8F8', type: ShadingType.CLEAR }), border: bqBorderObj,
shading: bqShading,
spacing: { spacing: {
before: (bqConfig?.spacing?.before || 12) * 20, ...bqSpacing,
after: (bqConfig?.spacing?.after || 12) * 20, before: isFirst ? (bqConfig?.spacing?.before || 12) * 20 : bqSpacing.before,
line: Math.round((bqConfig?.spacing?.line || body.spacing?.line || 1.2) * 240) after: isLast ? (bqConfig?.spacing?.after || 12) * 20 : bqSpacing.after,
}
});
if (childEls.length === 0) {
// No child elements - process as single paragraph with full blockquote styling
const runs = processTextRuns(el, bqFmt);
results.push(makeBqParagraph(runs, true, true, mapAlignment(bqConfig?.align)));
} else {
childEls.forEach((child, i) => {
const childEl = child as HTMLElement;
const childTagName = childEl.tagName.toLowerCase();
// Nested blockquotes - recurse
if (childTagName === 'blockquote') {
results.push(...processNode(childEl));
return;
}
const isP = childTagName === 'p';
// CSS specificity: <p> rules override inherited blockquote styles
// Only italic inherits since .page p doesn't set font-style
const childFmt = isP ? {
font: body.font,
size: pt(body.size),
color: formatColor(resolveColorToHex(body.color)),
italics: bqConfig?.italic !== false
} : bqFmt;
const childAlign = isP ? mapAlignment(elements?.p?.align || body.align) : mapAlignment(bqConfig?.align);
const runs = processTextRuns(childEl, childFmt);
if (runs.length > 0) {
results.push(makeBqParagraph(runs, i === 0, i === childEls.length - 1, childAlign));
}
});
} }
}));
return results; return results;
} }
// Lists // Lists - with spacing before/after the list container
if (tag === 'ul' || tag === 'ol') { if (tag === 'ul' || tag === 'ol') {
const listCfg = tag === 'ul' ? elements?.ul : elements?.ol;
const listBefore = (listCfg?.spacing?.before ?? 12) * 20;
const listAfter = (listCfg?.spacing?.after ?? 12) * 20;
if (listBefore) results.push(new Paragraph({ spacing: { before: listBefore, after: 0 }, children: [] }));
results.push(...processList(el, tag === 'ol', 0)); results.push(...processList(el, tag === 'ol', 0));
if (listAfter) results.push(new Paragraph({ spacing: { before: 0, after: listAfter }, children: [] }));
return results; return results;
} }
// Images - produce accessible placeholder text // Images - embed if fetched, otherwise placeholder
if (tag === 'img') { if (tag === 'img') {
const src = el.getAttribute('src');
const cached = src ? imageCache.get(src) : null;
if (cached) {
const imgRun = createInlineImageRun(src);
if (imgRun) {
results.push(new Paragraph({
children: [imgRun],
alignment: AlignmentType.CENTER,
spacing: { before: (elements?.img?.spacing?.before ?? 18) * 20, after: (elements?.img?.spacing?.after ?? 18) * 20 },
}));
}
} else {
const alt = el.getAttribute('alt') || ''; const alt = el.getAttribute('alt') || '';
const placeholderText = alt ? `[Image: ${alt}]` : '[Image]'; const placeholderText = alt ? `[Image: ${alt}]` : '[Image]';
results.push(new Paragraph({ results.push(new Paragraph({
@@ -1217,6 +1428,7 @@ export const generateDocxDocument = async (
})], })],
spacing: { before: 120, after: 120 }, spacing: { before: 120, after: 120 },
})); }));
}
return results; return results;
} }
@@ -1234,12 +1446,82 @@ export const generateDocxDocument = async (
}, },
spacing: { spacing: {
before: (hrConfig?.spacing?.before || 12) * 20, before: (hrConfig?.spacing?.before || 12) * 20,
after: (hrConfig?.spacing?.after || 12) * 20 after: 0
} }
})); }));
return results; return results;
} }
// Divs - split into paragraphs for text/inline content, recurse for nested block elements
if (tag === 'div') {
const style = el.getAttribute('style') || '';
const alignMatch = style.match(/text-align:\s*(left|center|right|justify)/i);
const divAlign = alignMatch ? alignMatch[1].toLowerCase() : undefined;
const divSpacing = elements?.p?.spacing || body.spacing;
const blockTags = new Set(['div', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'ul', 'ol', 'table', 'pre', 'hr']);
// Check if div has nested block elements
const hasBlockChildren = Array.from(el.children).some(c => blockTags.has(c.tagName.toLowerCase()));
if (hasBlockChildren) {
// Split: group consecutive inline/text nodes into paragraphs, recurse block elements
let inlineNodes: Node[] = [];
const flushInline = () => {
if (inlineNodes.length === 0) return;
// Create a temp container in the parsed document to process inline nodes
const temp = doc.createElement('span');
inlineNodes.forEach(n => temp.appendChild(n.cloneNode(true)));
const text = temp.textContent?.trim();
if (text) {
const runs = processTextRuns(temp as any, {
font: body.font, size: pt(body.size), color: formatColor(resolveColorToHex(body.color))
});
if (runs.length > 0) {
results.push(new Paragraph({
children: runs,
alignment: divAlign ? mapAlignment(divAlign === 'justify' ? 'both' : divAlign) : mapAlignment(body.align),
spacing: { before: 0, after: (divSpacing?.after || body.size) * 20, line: Math.round((divSpacing?.line || 1.2) * 240) }
}));
}
}
inlineNodes = [];
};
for (const child of Array.from(el.childNodes)) {
if (child.nodeType === Node.ELEMENT_NODE && blockTags.has((child as HTMLElement).tagName.toLowerCase())) {
flushInline();
results.push(...processNode(child));
} else {
inlineNodes.push(child);
}
}
flushInline();
} else {
// No nested blocks - treat entire div as one paragraph
const hasContent = el.textContent?.trim();
if (hasContent) {
const runs = processTextRuns(el, {
font: body.font, size: pt(body.size), color: formatColor(resolveColorToHex(body.color))
});
if (runs.length > 0) {
const isImgOnly = el.querySelector('img') !== null && !hasContent;
results.push(new Paragraph({
children: runs,
alignment: isImgOnly ? AlignmentType.CENTER : (divAlign ? mapAlignment(divAlign === 'justify' ? 'both' : divAlign) : mapAlignment(body.align)),
spacing: { before: 0, after: (divSpacing?.after || body.size) * 20, line: Math.round((divSpacing?.line || 1.2) * 240) }
}));
return results;
}
}
// No text - process children for images etc.
for (const child of Array.from(el.childNodes)) {
results.push(...processNode(child));
}
}
return results;
}
// Default: process children // Default: process children
for (const child of Array.from(el.childNodes)) { for (const child of Array.from(el.childNodes)) {
results.push(...processNode(child)); results.push(...processNode(child));

View File

@@ -0,0 +1,89 @@
import TurndownService from 'turndown';
// @ts-ignore
import { gfm } from 'turndown-plugin-gfm';
export function htmlToMarkdown(html: string, preserveAlignment = false): string {
const turndown = new TurndownService({
headingStyle: 'atx',
hr: '---',
bulletListMarker: '-',
codeBlockStyle: 'fenced',
emDelimiter: '*',
strongDelimiter: '**',
});
// Enable GFM tables
turndown.use(gfm);
// Convert Blogger caption tables (image + caption) to image + italic caption
// These are layout tables, not data tables - without this rule, GFM converts them
// to markdown tables which then get data table borders in the export
turndown.addRule('bloggerCaptionTable', {
filter: (node) => {
return node.nodeName === 'TABLE' &&
(node as HTMLElement).classList.contains('tr-caption-container');
},
replacement: (_content, node) => {
const el = node as HTMLElement;
const img = el.querySelector('img');
const caption = el.querySelector('.tr-caption');
let result = '\n\n';
if (img) {
const src = img.getAttribute('src') || '';
const alt = img.getAttribute('alt') || '';
const link = img.closest('a');
if (link) {
result += `[![${alt}](${src})](${link.getAttribute('href')})\n`;
} else {
result += `![${alt}](${src})\n`;
}
}
if (caption && caption.textContent?.trim()) {
result += `\n*${caption.textContent.trim()}*\n`;
}
return result + '\n';
},
});
// Strip meaningless wrapper spans (Blogger, Google Docs, Word paste, etc.)
turndown.addRule('stripDecorativeSpans', {
filter: (node) => {
if (node.nodeName !== 'SPAN') return false;
const style = node.getAttribute('style') || '';
if (!style) return true;
const meaningless = /font-family:\s*inherit|font-size:\s*(medium|inherit)|font-weight:\s*normal|color:\s*(black|inherit)/i;
const props = style.split(';').map(p => p.trim()).filter(Boolean);
return props.length > 0 && props.every(p => meaningless.test(p));
},
replacement: (content) => content,
});
// For HTML content: preserve divs with text-align as raw HTML pass-through
if (preserveAlignment) {
turndown.addRule('preserveAlignment', {
filter: (node) => {
if (node.nodeName !== 'DIV') return false;
const style = node.getAttribute('style') || '';
return /text-align:\s*(right|center)/i.test(style);
},
replacement: (_content, node) => {
const el = node as HTMLElement;
return '\n\n' + el.outerHTML + '\n\n';
},
});
}
let markdown = turndown.turndown(html);
// Clean up excessive blank lines
markdown = markdown.replace(/\n{3,}/g, '\n\n');
// Convert non-breaking spaces to &nbsp; entities (NOT regular spaces)
// Regular spaces would trigger markdown code block detection at 4+ indent
// &nbsp; entities pass through Marked.js as HTML and render as visible spaces
markdown = markdown.replace(/\u00A0/g, '&nbsp;');
// Clean up whitespace-only lines but DON'T strip trailing spaces on content lines
// Turndown uses two trailing spaces for <br> line breaks - stripping them breaks line breaks
markdown = markdown.replace(/^[ \t]+$/gm, '');
return markdown.trim();
}