From e1e46015ea0fcb73c05d216f694562eb4d41f3ea Mon Sep 17 00:00:00 2001 From: TypoGenie Date: Thu, 29 Jan 2026 18:10:07 +0200 Subject: [PATCH] Initial commit: TypoGenie - Markdown to Word document converter --- .gitignore | 24 + App.tsx | 165 +++++ README.md | 16 + components/FileUpload.tsx | 92 +++ components/Preview.tsx | 374 +++++++++++ components/StylePreviewModal.tsx | 128 ++++ components/StyleSelector.tsx | 263 ++++++++ constants.ts | 3 + index.html | 36 + index.tsx | 15 + metadata.json | 5 + package.json | 24 + styles/academic.ts | 407 +++++++++++ styles/core.ts | 1 + styles/corporate.ts | 430 ++++++++++++ styles/creative.ts | 364 ++++++++++ styles/editorial.ts | 284 ++++++++ styles/index.ts | 22 + styles/industrial.ts | 617 +++++++++++++++++ styles/lifestyle.ts | 768 +++++++++++++++++++++ styles/minimalist.ts | 354 ++++++++++ styles/tech.ts | 957 ++++++++++++++++++++++++++ styles/vintage.ts | 1077 ++++++++++++++++++++++++++++++ styles/volume1.ts | 1 + styles/volume2.ts | 1 + tsconfig.json | 29 + types.ts | 68 ++ vite.config.ts | 20 + 28 files changed, 6545 insertions(+) create mode 100644 .gitignore create mode 100644 App.tsx create mode 100644 README.md create mode 100644 components/FileUpload.tsx create mode 100644 components/Preview.tsx create mode 100644 components/StylePreviewModal.tsx create mode 100644 components/StyleSelector.tsx create mode 100644 constants.ts create mode 100644 index.html create mode 100644 index.tsx create mode 100644 metadata.json create mode 100644 package.json create mode 100644 styles/academic.ts create mode 100644 styles/core.ts create mode 100644 styles/corporate.ts create mode 100644 styles/creative.ts create mode 100644 styles/editorial.ts create mode 100644 styles/index.ts create mode 100644 styles/industrial.ts create mode 100644 styles/lifestyle.ts create mode 100644 styles/minimalist.ts create mode 100644 styles/tech.ts create mode 100644 styles/vintage.ts create mode 100644 styles/volume1.ts create mode 100644 styles/volume2.ts create mode 100644 tsconfig.json create mode 100644 types.ts create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..f8b2106 --- /dev/null +++ b/App.tsx @@ -0,0 +1,165 @@ +import React, { useState } from 'react'; +import { AppState, PaperSize } from './types'; +import { FileUpload } from './components/FileUpload'; +import { StyleSelector } from './components/StyleSelector'; +import { Preview } from './components/Preview'; +// @ts-ignore +import { parse } from 'marked'; +import { Sparkles, Loader2, FileType } from 'lucide-react'; + +const App: React.FC = () => { + const [appState, setAppState] = useState(AppState.UPLOAD); + const [content, setContent] = useState(''); + const [selectedStyle, setSelectedStyle] = useState(null); + const [paperSize, setPaperSize] = useState('Letter'); + const [generatedHtml, setGeneratedHtml] = useState(''); + const [error, setError] = useState(null); + + const handleFileLoaded = (text: string) => { + setContent(text); + setAppState(AppState.CONFIG); + }; + + const handleGenerate = async () => { + if (!selectedStyle || !content) return; + + setAppState(AppState.GENERATING); + setError(null); + + try { + // Small artificial delay to show the "Processing" state for better UX, + // otherwise it flickers too fast since local parsing is instant. + await new Promise(resolve => setTimeout(resolve, 800)); + + // Parse markdown to HTML using the local 'marked' library + const html = await parse(content); + + if (!html) throw new Error("No content generated"); + + setGeneratedHtml(html); + setAppState(AppState.PREVIEW); + } catch (err) { + console.error(err); + setError("Failed to process the document. Please check your file format and try again."); + setAppState(AppState.CONFIG); + } + }; + + const handleReset = () => { + setAppState(AppState.UPLOAD); + setContent(''); + setGeneratedHtml(''); + setSelectedStyle(null); + }; + + const handleBackToConfig = () => { + setAppState(AppState.CONFIG); + }; + + // Render Logic + if (appState === AppState.PREVIEW) { + // Pass selectedStyleId to Preview so it can lookup font config + // We add the prop via spread or explicit + return ( + // @ts-ignore - Adding prop dynamically if interface not fully updated in previous file change block (it was) + + ); + } + + return ( +
+ + {/* Header */} +
+
+
+
+ +
+

TypoGenie

+
+ {appState !== AppState.UPLOAD && ( +
+ Configure + / + Generate + / + Preview +
+ )} +
+
+ + {/* Main Content */} +
+
+
+
+
+ +
+ + {appState === AppState.UPLOAD && ( +
+
+

+ Turn Markdown into
+ + Professional Word Docs. + +

+

+ Upload your raw text. Select a style. + Get a formatted DOCX file ready for Microsoft Word. +

+
+ +
+ )} + + {appState === AppState.CONFIG && ( +
+ + {error && ( +
+ {error} +
+ )} +
+ )} + + {appState === AppState.GENERATING && ( +
+
+
+ +
+

Formatting Document

+

+ Applying typographic rules and preparing print-ready layout... +

+
+ )} +
+
+ +
+

TypoGenie - Professional Typesetting Engine

+
+
+ ); +}; + +export default App; \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..51909c7 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +
+GHBanner +
+ +# TypoGenie + +A professional typesetting engine that converts Markdown files into beautifully formatted Microsoft Word documents. + +## Run Locally + +**Prerequisites:** Node.js + +1. Install dependencies: + `npm install` +2. Run the app: + `npm run dev` diff --git a/components/FileUpload.tsx b/components/FileUpload.tsx new file mode 100644 index 0000000..47d48b0 --- /dev/null +++ b/components/FileUpload.tsx @@ -0,0 +1,92 @@ +import React, { useCallback, useState } from 'react'; +import { Upload, FileText, AlertCircle } from 'lucide-react'; + +interface FileUploadProps { + onFileLoaded: (content: string) => void; +} + +export const FileUpload: React.FC = ({ onFileLoaded }) => { + const [dragActive, setDragActive] = useState(false); + const [error, setError] = useState(null); + + const handleFile = (file: File) => { + setError(null); + if (!file.name.endsWith('.md') && !file.name.endsWith('.txt') && !file.name.endsWith('.markdown')) { + setError('Please upload a Markdown (.md) or Text (.txt) file.'); + return; + } + + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target?.result; + if (typeof text === 'string') { + onFileLoaded(text); + } + }; + reader.onerror = () => setError('Error reading file.'); + reader.readAsText(file); + }; + + const handleDrag = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.type === 'dragenter' || e.type === 'dragover') { + setDragActive(true); + } else if (e.type === 'dragleave') { + setDragActive(false); + } + }, []); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + if (e.dataTransfer.files && e.dataTransfer.files[0]) { + handleFile(e.dataTransfer.files[0]); + } + }, []); + + const handleChange = (e: React.ChangeEvent) => { + e.preventDefault(); + if (e.target.files && e.target.files[0]) { + handleFile(e.target.files[0]); + } + }; + + return ( +
+
+ + +
+
+ +
+

+ Click to upload or drag and drop +

+

Markdown or Plain Text files

+
+
+ + {error && ( +
+ + {error} +
+ )} +
+ ); +}; diff --git a/components/Preview.tsx b/components/Preview.tsx new file mode 100644 index 0000000..3d68718 --- /dev/null +++ b/components/Preview.tsx @@ -0,0 +1,374 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { ArrowLeft, Download, FileText, CheckCircle2, ExternalLink } from 'lucide-react'; +import { PaperSize, DocxStyleConfig, DocxBorder } from '../types'; +import { TYPOGRAPHY_STYLES } from '../constants'; +// @ts-ignore +import * as docx from 'docx'; + +interface PreviewProps { + htmlContent: string; + onBack: () => void; + paperSize: PaperSize; + selectedStyleId?: string | null; +} + +export const Preview: React.FC = ({ htmlContent, onBack, paperSize, selectedStyleId }) => { + const iframeRef = useRef(null); + const [isExporting, setIsExporting] = useState(false); + const [successMsg, setSuccessMsg] = useState(false); + + // Get current style + const style = TYPOGRAPHY_STYLES.find(s => s.id === selectedStyleId) || TYPOGRAPHY_STYLES[0]; + + // Extract unique fonts for display + const usedFonts = Array.from(new Set([ + style.wordConfig.heading1.font, + style.wordConfig.heading2.font, + style.wordConfig.body.font + ])).filter(Boolean); + + // Helper to convert Points to Half-Points (docx standard) + const pt = (points: number) => points * 2; + + // Helper to convert Inches/MM to Twips + const inchesToTwips = (inches: number) => Math.round(inches * 1440); + const mmToTwips = (mm: number) => Math.round(mm * (1440 / 25.4)); + + // Helper to map Border config to Docx Border + const mapBorder = (b?: DocxBorder) => { + if (!b) return undefined; + let style = docx.BorderStyle.SINGLE; + if (b.style === 'double') style = docx.BorderStyle.DOUBLE; + if (b.style === 'dotted') style = docx.BorderStyle.DOTTED; + if (b.style === 'dashed') style = docx.BorderStyle.DASHED; + + return { + color: b.color, + space: b.space, + style: style, + size: b.size + }; + }; + + const generateDocx = async (styleId: string) => { + setIsExporting(true); + try { + const style = TYPOGRAPHY_STYLES.find(s => s.id === styleId) || TYPOGRAPHY_STYLES[0]; + const cfg = style.wordConfig; + + // PARSE HTML + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlContent, 'text/html'); + const nodes = Array.from(doc.body.childNodes); + + const docxChildren = []; + + for (const node of nodes) { + if (node.nodeType !== Node.ELEMENT_NODE) continue; + const el = node as HTMLElement; + const tagName = el.tagName.toLowerCase(); + + // --- Run Parser (Inline Styles) --- + const parseRuns = (element: HTMLElement, baseConfig: DocxStyleConfig) => { + const runs = []; + for (const child of Array.from(element.childNodes)) { + if (child.nodeType === Node.TEXT_NODE) { + runs.push(new docx.TextRun({ + text: child.textContent || '', + font: baseConfig.font, + size: pt(baseConfig.size), + color: baseConfig.color, + underline: baseConfig.underline ? { type: docx.UnderlineType.SINGLE, color: baseConfig.color } : undefined, + })); + } else if (child.nodeType === Node.ELEMENT_NODE) { + const childEl = child as HTMLElement; + const isBold = childEl.tagName === 'STRONG' || childEl.tagName === 'B'; + const isItalic = childEl.tagName === 'EM' || childEl.tagName === 'I'; + runs.push(new docx.TextRun({ + text: childEl.textContent || '', + bold: isBold, + italics: isItalic, + font: baseConfig.font, + size: pt(baseConfig.size), + color: baseConfig.color, + underline: baseConfig.underline ? { type: docx.UnderlineType.SINGLE, color: baseConfig.color } : undefined, + })); + } + } + return runs; + }; + + + // --- Block Parser --- + + // 1. HEADINGS + if (tagName.match(/^h[1-6]$/)) { + const level = parseInt(tagName.replace('h', '')); + + // Select config based on level (simplified to H1 or H2, fallback H2 for others) + const hCfg = level === 1 ? cfg.heading1 : cfg.heading2; + const headingLevel = level === 1 ? docx.HeadingLevel.HEADING_1 : docx.HeadingLevel.HEADING_2; + + // Border Mapping + const borderConfig: any = {}; + if (hCfg.border) { + if (hCfg.border.top) borderConfig.top = mapBorder(hCfg.border.top); + if (hCfg.border.bottom) borderConfig.bottom = mapBorder(hCfg.border.bottom); + if (hCfg.border.left) borderConfig.left = mapBorder(hCfg.border.left); + if (hCfg.border.right) borderConfig.right = mapBorder(hCfg.border.right); + } + + // Alignment Mapping + let align = docx.AlignmentType.LEFT; + if (hCfg.align === 'center') align = docx.AlignmentType.CENTER; + if (hCfg.align === 'right') align = docx.AlignmentType.RIGHT; + if (hCfg.align === 'both') align = docx.AlignmentType.BOTH; + + docxChildren.push(new docx.Paragraph({ + children: [ + new docx.TextRun({ + text: el.textContent || '', + font: hCfg.font, + bold: hCfg.bold, + italics: hCfg.italic, + underline: hCfg.underline ? { type: docx.UnderlineType.SINGLE, color: hCfg.color } : undefined, + size: pt(hCfg.size), + color: hCfg.color, + allCaps: hCfg.allCaps, + smallCaps: hCfg.smallCaps, + characterSpacing: hCfg.tracking + }) + ], + heading: headingLevel, + alignment: align, + spacing: { + before: hCfg.spacing?.before, + after: hCfg.spacing?.after, + line: hCfg.spacing?.line + }, + border: borderConfig, + shading: hCfg.shading ? { + fill: hCfg.shading.fill, + color: hCfg.shading.color, + type: docx.ShadingType.CLEAR // usually clear to show fill + } : undefined, + keepNext: true, + keepLines: true + })); + } + + // 2. PARAGRAPHS + else if (tagName === 'p') { + let align = docx.AlignmentType.LEFT; + if (cfg.body.align === 'center') align = docx.AlignmentType.CENTER; + if (cfg.body.align === 'right') align = docx.AlignmentType.RIGHT; + if (cfg.body.align === 'both') align = docx.AlignmentType.BOTH; + + docxChildren.push(new docx.Paragraph({ + children: parseRuns(el, cfg.body), + spacing: { + before: cfg.body.spacing?.before, + after: cfg.body.spacing?.after, + line: cfg.body.spacing?.line, + lineRule: docx.LineRuleType.AUTO + }, + alignment: align + })); + } + + // 3. BLOCKQUOTES + else if (tagName === 'blockquote') { + docxChildren.push(new docx.Paragraph({ + children: parseRuns(el, { ...cfg.body, size: cfg.body.size + 1, color: cfg.accentColor, italic: true } as DocxStyleConfig), + indent: { left: 720 }, // 0.5 inch + border: { left: { color: cfg.accentColor, space: 10, style: docx.BorderStyle.SINGLE, size: 24 } }, + shading: { fill: "F8F8F8", type: docx.ShadingType.CLEAR, color: "auto" }, // Default light grey background for quotes + spacing: { before: 200, after: 200, line: 300 } + })); + } + + // 4. LISTS + else if (tagName === 'ul' || tagName === 'ol') { + const listItems = Array.from(el.children); + for (const li of listItems) { + docxChildren.push(new docx.Paragraph({ + children: parseRuns(li as HTMLElement, cfg.body), + bullet: { level: 0 }, + spacing: { before: 80, after: 80 } + })); + } + } + } + + // Create Document + const docxFile = new docx.Document({ + sections: [{ + properties: { + page: { + size: { + width: paperSize === 'A4' ? mmToTwips(210) : inchesToTwips(8.5), + height: paperSize === 'A4' ? mmToTwips(297) : inchesToTwips(11), + }, + margin: { + top: inchesToTwips(1), + right: inchesToTwips(1.2), + bottom: inchesToTwips(1), + left: inchesToTwips(1.2), + } + } + }, + children: docxChildren + }] + }); + + const blob = await docx.Packer.toBlob(docxFile); + + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `typogenie-${style.name.replace(/\s+/g, '-').toLowerCase()}.docx`; + a.click(); + window.URL.revokeObjectURL(url); + + setSuccessMsg(true); + setTimeout(() => setSuccessMsg(false), 3000); + + } catch (e) { + console.error("Docx Gen Error", e); + alert("Failed to generate DOCX"); + } finally { + setIsExporting(false); + } + }; + + useEffect(() => { + if (!iframeRef.current) return; + + // We already have 'style' from the component scope, but useEffect needs to be robust + const currentStyle = TYPOGRAPHY_STYLES.find(s => s.id === selectedStyleId) || TYPOGRAPHY_STYLES[0]; + + const doc = iframeRef.current.contentDocument; + if (doc) { + const html = ` + + + + + + + + + +
+ ${htmlContent} +
+ + + `; + doc.open(); + doc.write(html); + doc.close(); + } + }, [htmlContent, paperSize, selectedStyleId]); + + return ( +
+ {/* Toolbar */} +
+
+ + +
+ + {/* Font List */} +
+ Fonts: +
+ {usedFonts.map(font => ( + + {font} + + + ))} +
+
+ +
+ +
+ + Format: {paperSize} + + +
+
+
+
+ +
+