Compare commits
25 Commits
v1.2.0
..
8b55855c81
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b55855c81 | |||
| 63cfed02d9 | |||
| 87dd578e92 | |||
| 2ca1439680 | |||
| bd842b87a2 | |||
| 1458210d88 | |||
| e460f4df68 | |||
| d0f88625b5 | |||
| 242e16f75d | |||
| 7d5af9e39c | |||
| 3b8e80c3a3 | |||
| 3ab4432fcf | |||
| 32c9ab9e92 | |||
| 592ded54fe | |||
| f34aa9e9a8 | |||
| a1f3a86022 | |||
| 285b9e835d | |||
| 3265ea899b | |||
| 4a8ca94348 | |||
| 30cff63353 | |||
| 60f39ed961 | |||
| a2631ac473 | |||
| 2c22b0fce6 | |||
| ae5ce9d243 | |||
| f179e79f35 |
@@ -79,7 +79,7 @@ In a world where document formatting tools are increasingly locked behind paywal
|
|||||||
|
|
||||||
### 🎯 Core Capabilities
|
### 🎯 Core Capabilities
|
||||||
|
|
||||||
- **📄 Universal Input Support** - Drop in any `.md`, `.html`, `.txt`, or `.markdown` file - HTML content is auto-detected and converted
|
- **📄 Universal Markdown Support** - Drop in any `.md`, `.txt`, or `.markdown` file
|
||||||
- **🎨 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 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 |
|
| **Image Placeholders** | `<img>` elements in Markdown are converted to italic `[Image: alt text]` placeholders rather than silently dropped |
|
||||||
| **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-v1.2.0-Portable.zip`](https://git.lashman.live/lashman/typogenie/releases) - Extract and run, nothing else needed
|
- 🪟 **Windows**: `TypoGenie.exe` - Single executable, runs immediately
|
||||||
|
|
||||||
**How it works:**
|
**How it works:**
|
||||||
```
|
```
|
||||||
@@ -294,7 +294,7 @@ npm run desktop:build
|
|||||||
|
|
||||||
### Usage
|
### Usage
|
||||||
|
|
||||||
1. **📤 Upload** - Drag and drop your Markdown, HTML, or text file (or click to browse)
|
1. **📤 Upload** - Drag and drop your Markdown 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 │ │
|
||||||
│ │ Input → detect/convert → Markdown → marked → HTML → .docx │ │
|
│ │ Markdown → marked parser → HTML → docx library → .docx │ │
|
||||||
│ └────────────────────────────────────────────────────────────┘ │
|
│ └────────────────────────────────────────────────────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
└─────────────────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
@@ -428,10 +428,8 @@ 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 with image embedding
|
│ │ └── docxConverter.ts # Accessible DOCX generation
|
||||||
│ │ └── 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)
|
||||||
@@ -455,7 +453,6 @@ 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 |
|
||||||
@@ -563,6 +560,8 @@ If TypoGenie brings value to your life, consider:
|
|||||||
|
|
||||||
<p>
|
<p>
|
||||||
<i>"The master's tools will never dismantle the master's house. But they might help you format a really nice document while we build something better together."</i>
|
<i>"The master's tools will never dismantle the master's house. But they might help you format a really nice document while we build something better together."</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.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
@@ -574,5 +573,3 @@ 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§ion=footer" alt="Footer" />
|
<img src="https://capsule-render.vercel.app/api?type=waving&color=0:a855f7,50:8b5cf6,100:6366f1&height=100§ion=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>
|
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
# WCAG 2.2 AAA Accessibility Remediation Design
|
||||||
|
|
||||||
|
**Date:** 2026-02-18
|
||||||
|
**Status:** Approved
|
||||||
|
**Scope:** 147 accessibility issues across 17 existing files, 3 new files
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Decision | Choice |
|
||||||
|
|----------|--------|
|
||||||
|
| Visual approach | Balance accessibility with dark aesthetic |
|
||||||
|
| Modals | Native `<dialog>` element |
|
||||||
|
| Style cards | Listbox pattern with existing `useFocusableList` hook |
|
||||||
|
| Template colors | Runtime contrast validation only, no JSON template edits |
|
||||||
|
| Justified text | Override to `left` at render time |
|
||||||
|
| Contrast enforcement | Auto-correct via `ensureContrast()` utility at render time |
|
||||||
|
|
||||||
|
## Section 1: CSS Foundation (~25 issues)
|
||||||
|
|
||||||
|
### Root font size
|
||||||
|
Change `:root { font-size: 16px }` to `font-size: 100%` in `index.css` so user browser preferences are respected.
|
||||||
|
|
||||||
|
### Overflow
|
||||||
|
Remove `overflow: hidden` from `body` and `#root`. Replace with `overflow-x: hidden` to prevent horizontal scroll but allow vertical content access when zoomed to 200%.
|
||||||
|
|
||||||
|
### Focus indicators
|
||||||
|
Replace `rgba(99, 102, 241, 0.3)` focus ring with `rgba(99, 102, 241, 0.8)` at 2px. Meets 3:1 contrast on dark backgrounds while fitting indigo theme.
|
||||||
|
|
||||||
|
### Reduced motion
|
||||||
|
Add global CSS rule:
|
||||||
|
```css
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Forced colors
|
||||||
|
Add `@media (forced-colors: active)` rules for Windows High Contrast Mode compatibility.
|
||||||
|
|
||||||
|
### Color replacements
|
||||||
|
Across all components: `text-zinc-600` -> `text-zinc-400`, `text-zinc-500` -> `text-zinc-400`. All text hits 7:1+ contrast on zinc-950. Keeps dark aesthetic.
|
||||||
|
|
||||||
|
## Section 2: Modal System (~20 issues)
|
||||||
|
|
||||||
|
### New `src/hooks/useDialog.ts`
|
||||||
|
Reusable hook wrapping native `<dialog>`:
|
||||||
|
- Manages `dialogRef`, `showModal()`/`close()`
|
||||||
|
- Restores focus to trigger element on close
|
||||||
|
- Wires `aria-labelledby`
|
||||||
|
- Framer Motion animations on inner content div
|
||||||
|
|
||||||
|
### KeyboardShortcutsHelp (App.tsx)
|
||||||
|
Convert from `motion.div` overlay to `<dialog>`. Add `aria-labelledby` to heading. Close button gets `aria-label="Close shortcuts"`. Escape/focus trap handled natively.
|
||||||
|
|
||||||
|
### ExportOptionsModal
|
||||||
|
Same `<dialog>` conversion. Radio buttons wrapped in `<fieldset><legend>`. Close button labeled.
|
||||||
|
|
||||||
|
### StylePreviewModal
|
||||||
|
Same `<dialog>` conversion. Close button labeled. Iframe HTML gets `lang="en"`.
|
||||||
|
|
||||||
|
### Animation approach
|
||||||
|
`dialog[open]` CSS selector for entrance. `::backdrop` gets fade-in. Framer Motion on inner content for exit.
|
||||||
|
|
||||||
|
## Section 3: Keyboard Access & Interactive Elements (~20 issues)
|
||||||
|
|
||||||
|
### Logo button
|
||||||
|
Replace `<motion.div onClick>` with `<motion.button aria-label="TypoGenie - Reset to home">`.
|
||||||
|
|
||||||
|
### Single-character shortcut
|
||||||
|
Scope `?`/`/` listener to only fire when `document.activeElement` is body or non-input element. Prevents intercepting typing in search.
|
||||||
|
|
||||||
|
### Style cards as listbox
|
||||||
|
Container: `role="listbox"` + `aria-label="Typography styles"`. Each card: `role="option"` + `tabIndex` + `aria-selected` + `onKeyDown`. Wire `useFocusableList` for arrow keys. Add `:focus-visible` indigo ring.
|
||||||
|
|
||||||
|
### Favorite button
|
||||||
|
Add `aria-label` (dynamic: "Add to favorites"/"Remove from favorites") + `aria-pressed`.
|
||||||
|
|
||||||
|
### Category filters
|
||||||
|
Wrap in `<div role="group" aria-label="Filter by category">`. Active button: `aria-pressed="true"`.
|
||||||
|
|
||||||
|
### Paper size buttons
|
||||||
|
Wrap in `<div role="group" aria-label="Paper size">`. Active button: `aria-pressed="true"`.
|
||||||
|
|
||||||
|
### Search input
|
||||||
|
Add `aria-label="Search templates"` + `aria-describedby` pointing to live result count region.
|
||||||
|
|
||||||
|
### Icon-only buttons
|
||||||
|
Add `aria-label` to all: zoom buttons, external link (Preview), close buttons. Decorative icons next to text: `aria-hidden="true"`.
|
||||||
|
|
||||||
|
### Target sizes
|
||||||
|
Increase padding on small buttons to hit 44x44px minimum. Use `min-w-[44px] min-h-[44px]` with centered content where inline growth not possible.
|
||||||
|
|
||||||
|
## Section 4: ARIA Live Regions & Status Announcements (~15 issues)
|
||||||
|
|
||||||
|
### Root status region
|
||||||
|
Persistent `<div role="status" aria-live="polite" className="sr-only">` in App.tsx root. Updated on `appState` changes.
|
||||||
|
|
||||||
|
### Generating state
|
||||||
|
Add `aria-busy="true"` + `role="status"` to container.
|
||||||
|
|
||||||
|
### Preview loading
|
||||||
|
Wrap "Loading..." in `role="status"`.
|
||||||
|
|
||||||
|
### Success message
|
||||||
|
`aria-live="polite"` on button text area for "Saved!" announcement.
|
||||||
|
|
||||||
|
### Search results
|
||||||
|
Visually-hidden `role="status"` announcing "{N} templates found". Debounced 300ms.
|
||||||
|
|
||||||
|
### Error fixes
|
||||||
|
- Remove `aria-live="polite"` where `role="alert"` already set (implies assertive)
|
||||||
|
- Remove 5-second auto-dismiss from FileUpload error
|
||||||
|
- Add `aria-describedby` linking FileUpload error to dropzone
|
||||||
|
- Template loading: `role="status"`. Template error: `role="alert"`
|
||||||
|
|
||||||
|
### Loading state
|
||||||
|
Replace `return null` with minimal `<div role="status">Loading TypoGenie</div>`.
|
||||||
|
|
||||||
|
## Section 5: Semantic Structure & Landmarks (~15 issues)
|
||||||
|
|
||||||
|
### Skip navigation
|
||||||
|
`<a href="#main-content" class="sr-only focus:not-sr-only">Skip to main content</a>` in index.html.
|
||||||
|
|
||||||
|
### Page title
|
||||||
|
`<title>TypoGenie - Markdown to Word Converter</title>`
|
||||||
|
|
||||||
|
### Noscript
|
||||||
|
`<noscript><p>TypoGenie requires JavaScript to run.</p></noscript>`
|
||||||
|
|
||||||
|
### Landmarks
|
||||||
|
- `id="main-content"` on `<main>` in both app states
|
||||||
|
- `<nav aria-label="Style filters">` around category buttons
|
||||||
|
- `<section aria-label="Style list">` and `<section aria-label="Style preview">` in StyleSelector
|
||||||
|
|
||||||
|
### Progress stepper
|
||||||
|
Wrap in `<nav aria-label="Progress"><ol>`. Active step: `aria-current="step"`.
|
||||||
|
|
||||||
|
### Shortcuts list
|
||||||
|
Convert to `<dl>` with `<dt>`/`<dd>`.
|
||||||
|
|
||||||
|
### Decorative elements
|
||||||
|
`aria-hidden="true"` on blob backgrounds, window-chrome dots, visual dividers.
|
||||||
|
|
||||||
|
### Iframe lang
|
||||||
|
Both Preview.tsx and StylePreviewModal.tsx: inject `<html lang="en">`.
|
||||||
|
|
||||||
|
## Section 6: DOCX Output Accessibility (~8 issues)
|
||||||
|
|
||||||
|
### Document metadata
|
||||||
|
Add `title` (from filename/first h1), `description`, `language` to Document constructor.
|
||||||
|
|
||||||
|
### Image handling
|
||||||
|
New `<img>` handler producing `[Image: {alt text}]` placeholder paragraph in italic.
|
||||||
|
|
||||||
|
### Table headers
|
||||||
|
Set `tableHeader: true` on `TableRow` containing `<th>` elements.
|
||||||
|
|
||||||
|
### Heading preservation in table mode
|
||||||
|
Add `HeadingLevel` to paragraph inside `processHeaderAsTable` table cells.
|
||||||
|
|
||||||
|
### Code block style
|
||||||
|
Add custom "Code" paragraph style to DOCX styles. Apply to code blocks.
|
||||||
|
|
||||||
|
## Section 7: Template Contrast Validation (~20 issues)
|
||||||
|
|
||||||
|
### New `src/utils/contrastUtils.ts`
|
||||||
|
- `hexToRgb(hex)` - parse hex to RGB
|
||||||
|
- `relativeLuminance(rgb)` - WCAG luminance formula
|
||||||
|
- `contrastRatio(color1, color2)` - ratio calculation
|
||||||
|
- `ensureContrast(fg, bg, minRatio)` - auto-correct foreground color until ratio met
|
||||||
|
|
||||||
|
### Integration in templateRenderer.ts
|
||||||
|
Run `ensureContrast()` on every text/background pair at CSS generation time. Body text: 7:1. Large text (18pt+ or 14pt+ bold): 4.5:1.
|
||||||
|
|
||||||
|
### `del` element fix
|
||||||
|
When resolving `del` color, if `border` palette color fails contrast, fall back to `textSecondary`.
|
||||||
|
|
||||||
|
### Justified text
|
||||||
|
Override `justify` to `left` at render time (1.4.8 AAA prohibits justified text).
|
||||||
|
|
||||||
|
### Line-height floor
|
||||||
|
Enforce minimum 1.5 for body text, 1.0 for headings. Clamp upward if template specifies lower.
|
||||||
|
|
||||||
|
## Section 8: Remaining Miscellaneous (~10 issues)
|
||||||
|
|
||||||
|
### Iframe keyboard
|
||||||
|
`tabIndex={-1}` on preview iframe in StyleSelector (passive preview, not interactive).
|
||||||
|
|
||||||
|
### Error boundary
|
||||||
|
New `src/components/ErrorBoundary.tsx` wrapping `<App />`. Renders accessible error with `role="alert"` on crash.
|
||||||
|
|
||||||
|
### Framer Motion reduced motion
|
||||||
|
Import `useReducedMotion` from `motion/react` in App.tsx. When true, disable infinite blob/gradient animations via conditional animate props.
|
||||||
|
|
||||||
|
### Hidden buttons on small screens
|
||||||
|
Add always-visible `<kbd>?</kbd>` hint regardless of breakpoint.
|
||||||
|
|
||||||
|
### Truncation
|
||||||
|
Add `title` attribute with full description on `line-clamp-2` elements.
|
||||||
|
|
||||||
|
## Files Modified (17)
|
||||||
|
|
||||||
|
`index.html`, `src/index.css`, `src/App.tsx`, `src/main.tsx`, `src/components/StyleSelector.tsx`, `src/components/Preview.tsx`, `src/components/ExportOptionsModal.tsx`, `src/components/StylePreviewModal.tsx`, `src/components/FileUpload.tsx`, `src/hooks/useSettings.ts`, `src/hooks/useTemplates.ts`, `src/services/templateRenderer.ts`, `src/utils/docxConverter.ts`, `src/utils/fontUtils.ts`
|
||||||
|
|
||||||
|
## Files Created (3)
|
||||||
|
|
||||||
|
`src/utils/contrastUtils.ts`, `src/hooks/useDialog.ts`, `src/components/ErrorBoundary.tsx`
|
||||||
File diff suppressed because it is too large
Load Diff
Generated
+1
-25
@@ -21,14 +21,12 @@
|
|||||||
"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"
|
|
||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -824,12 +822,6 @@
|
|||||||
"@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",
|
||||||
@@ -1810,13 +1802,6 @@
|
|||||||
"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",
|
||||||
@@ -2905,15 +2890,6 @@
|
|||||||
"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/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",
|
||||||
|
|||||||
+3
-5
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "typogenie",
|
"name": "typogenie",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -18,23 +18,21 @@
|
|||||||
"@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"
|
|
||||||
},
|
},
|
||||||
"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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "typogenie"
|
name = "typogenie"
|
||||||
version = "1.2.0"
|
version = "1.0.0"
|
||||||
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"
|
||||||
|
|||||||
@@ -27,23 +27,25 @@
|
|||||||
"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:default",
|
"identifier": "http:allow-fetch",
|
||||||
"allow": [
|
"allow": [
|
||||||
{
|
{
|
||||||
"url": "https://*"
|
"url": "https://fonts.google.com/*"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"url": "http://*"
|
"url": "https://github.com/*"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"url": "https://*:*"
|
"url": "https://*.githubusercontent.com/*"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"url": "http://*:*"
|
"url": "https://fonts.googleapis.com/*"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.2.0",
|
"version": "1.0.0",
|
||||||
"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: 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:;",
|
"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:;",
|
||||||
"dangerousDisableAssetCspModification": true
|
"dangerousDisableAssetCspModification": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "creative-portfolio",
|
"id": "creative-portfolio",
|
||||||
"name": "Creative Portfolio",
|
"name": "Creative Portfolio",
|
||||||
"category": "creative",
|
"category": "creative",
|
||||||
"description": "Minimalist gallery style for presenting visual work. Focuses on images and clean typography.",
|
"description": "Minimalist gallery style for showcasing visual work. Focuses on images and clean typography.",
|
||||||
"vibe": "Minimalist, Visual, Gallery",
|
"vibe": "Minimalist, Visual, Gallery",
|
||||||
"googleFontsImport": "https://fonts.googleapis.com/css2?family=Jost:ital,wght@0,300..900;1,300..900&family=Playfair+Display:ital,wght@0,400..900;1,400..900&display=swap",
|
"googleFontsImport": "https://fonts.googleapis.com/css2?family=Jost:ital,wght@0,300..900;1,300..900&family=Playfair+Display:ital,wght@0,400..900;1,400..900&display=swap",
|
||||||
"typography": {
|
"typography": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "rococo-romance",
|
"id": "rococo-romance",
|
||||||
"name": "Rococo Romance",
|
"name": "Rococo Romance",
|
||||||
"category": "editorial",
|
"category": "editorial",
|
||||||
"description": "Inspired by 18th-century Rococo. Light, playful, and delicate with pastel tones and swirling elegance.",
|
"description": "Inspired by 18th-century Rococo. Light, playful, and intricate with pastel tones and swirling elegance.",
|
||||||
"vibe": "Romantic, Ornate, Pastel",
|
"vibe": "Romantic, Ornate, Pastel",
|
||||||
"googleFontsImport": "https://fonts.googleapis.com/css2?family=Pinyon+Script&family=Playfair+Display+SC:ital,wght@0,400;0,700;1,400;1,700&family=Lora:ital,wght@0,400..700;1,400..700&display=swap",
|
"googleFontsImport": "https://fonts.googleapis.com/css2?family=Pinyon+Script&family=Playfair+Display+SC:ital,wght@0,400;0,700;1,400;1,700&family=Lora:ital,wght@0,400..700;1,400..700&display=swap",
|
||||||
"typography": {
|
"typography": {
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"id": "neural-network",
|
"id": "artificial-intelligence",
|
||||||
"name": "Neural Network",
|
"name": "Neural Network",
|
||||||
"category": "tech",
|
"category": "tech",
|
||||||
"description": "Cutting-edge design for advanced computing and data science. Neural network inspired with gradient accents.",
|
"description": "Cutting-edge design for advanced computing and data science. Neural network inspired with gradient accents.",
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "tech-startup",
|
"id": "tech-startup",
|
||||||
"name": "Tech Venture",
|
"name": "Tech Venture",
|
||||||
"category": "tech",
|
"category": "tech",
|
||||||
"description": "Modern, energetic design for technology companies and ventures. Bold gradients, punchy colors, and confident typography.",
|
"description": "Modern, energetic design for technology companies and ventures. Bold gradients, vibrant colors, and confident typography.",
|
||||||
"vibe": "Innovative, Dynamic, Bold",
|
"vibe": "Innovative, Dynamic, Bold",
|
||||||
"googleFontsImport": "https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;700&display=swap",
|
"googleFontsImport": "https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;700&display=swap",
|
||||||
"typography": {
|
"typography": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "memphis-pattern",
|
"id": "memphis-pattern",
|
||||||
"name": "Memphis Pattern",
|
"name": "Memphis Pattern",
|
||||||
"category": "retro",
|
"category": "retro",
|
||||||
"description": "The chaotic 80s aesthetic. Clashing patterns, zig-zags, and loud pastel-neons.",
|
"description": "The chaotic 80s aesthetic. Clashing patterns, zig-zags, and vibrant pastel-neons.",
|
||||||
"vibe": "Playful, Chaotic, 80s",
|
"vibe": "Playful, Chaotic, 80s",
|
||||||
"googleFontsImport": "https://fonts.googleapis.com/css2?family=Righteous&family=Comfortaa:wght@400;700&display=swap",
|
"googleFontsImport": "https://fonts.googleapis.com/css2?family=Righteous&family=Comfortaa:wght@400;700&display=swap",
|
||||||
"typography": {
|
"typography": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "mid-century-modern",
|
"id": "mid-century-modern",
|
||||||
"name": "Mid-Century Modern",
|
"name": "Mid-Century Modern",
|
||||||
"category": "retro",
|
"category": "retro",
|
||||||
"description": "Optimistic and playful design from the 1950s-60s. Geometric sans-serifs, bold atomic-age colors, and approachable warmth.",
|
"description": "Optimistic and playful design from the 1950s-60s. Geometric sans-serifs, vibrant atomic-age colors, and approachable warmth.",
|
||||||
"vibe": "Playful, Nostalgic, Friendly",
|
"vibe": "Playful, Nostalgic, Friendly",
|
||||||
"googleFontsImport": "https://fonts.googleapis.com/css2?family=Quicksand:wght@400;600;700&display=swap",
|
"googleFontsImport": "https://fonts.googleapis.com/css2?family=Quicksand:wght@400;600;700&display=swap",
|
||||||
"typography": {
|
"typography": {
|
||||||
|
|||||||
+4
-42
@@ -10,9 +10,7 @@ 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, AlertCircle } from 'lucide-react';
|
import { Sparkles, Loader2, FileType, Keyboard, X, RefreshCw } from 'lucide-react';
|
||||||
import { detectContentType } from './utils/contentDetector';
|
|
||||||
import { htmlToMarkdown } from './utils/htmlToMarkdown';
|
|
||||||
|
|
||||||
import { useKeyboardNavigation } from './hooks/useKeyboardNavigation';
|
import { useKeyboardNavigation } from './hooks/useKeyboardNavigation';
|
||||||
|
|
||||||
@@ -94,7 +92,6 @@ 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();
|
||||||
@@ -138,32 +135,9 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleFileLoaded = (text: string, fullFileName: string = '') => {
|
const handleFileLoaded = (text: string, fileName: string = '') => {
|
||||||
setUploadError(null);
|
setContent(text);
|
||||||
|
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);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('HTML conversion failed:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setContent(processedContent);
|
|
||||||
setInputFileName(displayName);
|
|
||||||
setAppState(AppState.CONFIG);
|
setAppState(AppState.CONFIG);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -200,7 +174,6 @@ const App: React.FC = () => {
|
|||||||
setGeneratedHtml('');
|
setGeneratedHtml('');
|
||||||
setSelectedStyle(null);
|
setSelectedStyle(null);
|
||||||
setInputFileName('');
|
setInputFileName('');
|
||||||
setUploadError(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBackToConfig = () => {
|
const handleBackToConfig = () => {
|
||||||
@@ -398,17 +371,6 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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 m-0 w-full h-full border-none bg-black/50 flex items-center justify-center"
|
className="fixed inset-0 z-50 p-4"
|
||||||
>
|
>
|
||||||
<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"
|
||||||
|
|||||||
@@ -17,9 +17,8 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
|
|||||||
|
|
||||||
const handleFile = (file: File) => {
|
const handleFile = (file: File) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
if (!file.name.endsWith('.md') && !file.name.endsWith('.txt') && !file.name.endsWith('.markdown')) {
|
||||||
if (!['md', 'txt', 'markdown', 'html', 'htm'].includes(ext)) {
|
setError('Please upload a Markdown (.md) or Text (.txt) file.');
|
||||||
setError('Please upload a Markdown (.md), HTML (.html), or Text (.txt) file.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +26,9 @@ 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') {
|
||||||
onFileLoaded(text, file.name);
|
// Extract filename without extension
|
||||||
|
const fileName = file.name.replace(/\.[^/.]+$/, '');
|
||||||
|
onFileLoaded(text, fileName);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
reader.onerror = () => setError('Error reading file.');
|
reader.onerror = () => setError('Error reading file.');
|
||||||
@@ -131,7 +132,7 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
|
|||||||
type="file"
|
type="file"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
accept=".md,.txt,.markdown,.html,.htm"
|
accept=".md,.txt,.markdown"
|
||||||
aria-label="Select file"
|
aria-label="Select file"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -171,7 +172,7 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
|
|||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ delay: 0.7 }}
|
transition={{ delay: 0.7 }}
|
||||||
>
|
>
|
||||||
Markdown, HTML, or Plain Text files
|
Markdown 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"
|
||||||
|
|||||||
@@ -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, AlertTriangle } from 'lucide-react';
|
import { ArrowLeft, Download, FileText, CheckCircle2, ExternalLink, Loader2, ZoomIn, ZoomOut } 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,17 +142,14 @@ 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 (heading, body, and code)
|
// Extract used fonts for display
|
||||||
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({
|
||||||
@@ -168,24 +165,6 @@ 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);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -461,48 +440,6 @@ export const Preview: React.FC<PreviewProps> = ({
|
|||||||
<button onClick={() => setExportError(null)} className="ml-3 text-red-400 hover:text-white" aria-label="Dismiss error">✕</button>
|
<button onClick={() => setExportError(null)} className="ml-3 text-red-400 hover:text-white" aria-label="Dismiss error">✕</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -229,22 +229,6 @@ 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>
|
||||||
`;
|
`;
|
||||||
@@ -400,7 +384,6 @@ 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">
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
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' };
|
|
||||||
}
|
|
||||||
+16
-151
@@ -3,11 +3,10 @@ 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, ImageRun
|
ExternalHyperlink, TableBorders
|
||||||
} 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);
|
||||||
@@ -186,72 +185,6 @@ 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 dimensions from HTML attributes first
|
|
||||||
const htmlW = parseInt(img.getAttribute('data-original-width') || img.getAttribute('width') || '0');
|
|
||||||
const htmlH = parseInt(img.getAttribute('data-original-height') || 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
|
||||||
@@ -351,26 +284,6 @@ 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)[] = [];
|
||||||
@@ -447,14 +360,6 @@ 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 === '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;
|
||||||
@@ -593,14 +498,6 @@ 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 === '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;
|
||||||
@@ -625,16 +522,6 @@ 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];
|
||||||
@@ -673,12 +560,7 @@ export const generateDocxDocument = async (
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
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;
|
||||||
@@ -1263,13 +1145,9 @@ export const generateDocxDocument = async (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Center paragraphs that only contain an image
|
|
||||||
const isImageOnly = el.querySelector('img') !== null && !el.textContent?.trim();
|
|
||||||
|
|
||||||
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: isImageOnly ? AlignmentType.CENTER : mapAlignment(body.align),
|
alignment: mapAlignment(body.align),
|
||||||
indent: isImageOnly ? undefined : (elements?.p?.indent ? { firstLine: elements.p.indent * 20 } : undefined),
|
|
||||||
spacing: {
|
spacing: {
|
||||||
before: (body.spacing?.before || 0) * 20,
|
before: (body.spacing?.before || 0) * 20,
|
||||||
after: (body.spacing?.after || 0) * 20,
|
after: (body.spacing?.after || 0) * 20,
|
||||||
@@ -1325,33 +1203,20 @@ export const generateDocxDocument = async (
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Images - embed if fetched, otherwise placeholder
|
// Images - produce accessible placeholder text
|
||||||
if (tag === 'img') {
|
if (tag === 'img') {
|
||||||
const src = el.getAttribute('src');
|
const alt = el.getAttribute('alt') || '';
|
||||||
const cached = src ? imageCache.get(src) : null;
|
const placeholderText = alt ? `[Image: ${alt}]` : '[Image]';
|
||||||
|
results.push(new Paragraph({
|
||||||
if (cached) {
|
children: [new TextRun({
|
||||||
const imgRun = createInlineImageRun(src);
|
text: placeholderText,
|
||||||
if (imgRun) {
|
font: body.font,
|
||||||
results.push(new Paragraph({
|
size: pt(body.size),
|
||||||
children: [imgRun],
|
color: formatColor(resolveColorToHex(body.color) || '666666'),
|
||||||
spacing: { before: 120, after: 120 },
|
italics: true,
|
||||||
}));
|
})],
|
||||||
}
|
spacing: { before: 120, after: 120 },
|
||||||
} else {
|
}));
|
||||||
const alt = el.getAttribute('alt') || '';
|
|
||||||
const placeholderText = alt ? `[Image: ${alt}]` : '[Image]';
|
|
||||||
results.push(new Paragraph({
|
|
||||||
children: [new TextRun({
|
|
||||||
text: placeholderText,
|
|
||||||
font: body.font,
|
|
||||||
size: pt(body.size),
|
|
||||||
color: formatColor(resolveColorToHex(body.color) || '666666'),
|
|
||||||
italics: true,
|
|
||||||
})],
|
|
||||||
spacing: { before: 120, after: 120 },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import TurndownService from 'turndown';
|
|
||||||
|
|
||||||
export function htmlToMarkdown(html: string): string {
|
|
||||||
const turndown = new TurndownService({
|
|
||||||
headingStyle: 'atx',
|
|
||||||
hr: '---',
|
|
||||||
bulletListMarker: '-',
|
|
||||||
codeBlockStyle: 'fenced',
|
|
||||||
emDelimiter: '*',
|
|
||||||
strongDelimiter: '**',
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
});
|
|
||||||
|
|
||||||
let markdown = turndown.turndown(html);
|
|
||||||
|
|
||||||
// Clean up excessive blank lines
|
|
||||||
markdown = markdown.replace(/\n{3,}/g, '\n\n');
|
|
||||||
// Convert non-breaking spaces to entities (NOT regular spaces)
|
|
||||||
// Regular spaces would trigger markdown code block detection at 4+ indent
|
|
||||||
// entities pass through Marked.js as HTML and render as visible spaces
|
|
||||||
markdown = markdown.replace(/\u00A0/g, ' ');
|
|
||||||
// Clean up trailing whitespace on lines
|
|
||||||
markdown = markdown.replace(/[ \t]+$/gm, '');
|
|
||||||
|
|
||||||
return markdown.trim();
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user