sarveshpatel commited on
Commit
51179d9
Β·
verified Β·
1 Parent(s): 1810995

Upload 6 files

Browse files
Files changed (6) hide show
  1. Dockerfile +30 -0
  2. app/__init__.py +0 -0
  3. app/converter.py +1039 -0
  4. app/main.py +159 -0
  5. app/templates/index.html +709 -0
  6. requirements.txt +9 -0
Dockerfile ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # System deps for WeasyPrint + fonts
4
+ RUN apt-get update && apt-get install -y --no-install-recommends \
5
+ libpango-1.0-0 libpangocairo-1.0-0 libpangoft2-1.0-0 \
6
+ libgdk-pixbuf2.0-0 libffi-dev shared-mime-info \
7
+ libcairo2 libglib2.0-0 libxml2 libxslt1.1 \
8
+ fonts-noto fonts-noto-core fonts-noto-extra fonts-noto-cjk \
9
+ fonts-indic fonts-lohit-deva fonts-lohit-beng fonts-lohit-gujr \
10
+ fonts-lohit-taml fonts-lohit-telu fonts-lohit-knda fonts-lohit-mlym \
11
+ fonts-lohit-guru fonts-lohit-orya \
12
+ && fc-cache -f \
13
+ && rm -rf /var/lib/apt/lists/*
14
+
15
+ # Create non-root user for HuggingFace Spaces
16
+ RUN useradd -m -u 1000 user
17
+ WORKDIR /app
18
+
19
+ COPY requirements.txt .
20
+ RUN pip install --no-cache-dir -r requirements.txt
21
+
22
+ COPY . .
23
+
24
+ RUN mkdir -p /tmp/mdtodoc && chown -R user:user /tmp/mdtodoc /app
25
+
26
+ USER user
27
+
28
+ EXPOSE 7860
29
+
30
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
app/__init__.py ADDED
File without changes
app/converter.py ADDED
@@ -0,0 +1,1039 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Universal Markdown -> PDF / DOCX Converter (Web Edition)
3
+ Adapted from testing_script.py for FastAPI web app usage.
4
+ """
5
+
6
+ import logging
7
+ logging.getLogger("fontTools").setLevel(logging.WARNING)
8
+ logging.getLogger("weasyprint").setLevel(logging.ERROR)
9
+
10
+ import os, re, base64, time, zlib, json
11
+ import urllib.request, urllib.error
12
+ from pathlib import Path
13
+ from io import BytesIO
14
+ import threading
15
+
16
+ # ── Unicode script ranges ──
17
+ SCRIPT_RANGES = {
18
+ "devanagari": (0x0900, 0x097F),
19
+ "bengali": (0x0980, 0x09FF),
20
+ "gujarati": (0x0A80, 0x0AFF),
21
+ "tamil": (0x0B80, 0x0BFF),
22
+ "telugu": (0x0C00, 0x0C7F),
23
+ "kannada": (0x0C80, 0x0CFF),
24
+ "malayalam": (0x0D00, 0x0D7F),
25
+ "arabic": (0x0600, 0x06FF),
26
+ "cjk": (0x4E00, 0x9FFF),
27
+ "gurmukhi": (0x0A00, 0x0A7F),
28
+ "odia": (0x0B00, 0x0B7F),
29
+ }
30
+
31
+ FONT_CSS_MAP = {
32
+ "devanagari": "'Noto Sans Devanagari', 'Lohit Devanagari'",
33
+ "bengali": "'Noto Sans Bengali', 'Lohit Bengali'",
34
+ "gujarati": "'Noto Sans Gujarati', 'Lohit Gujarati'",
35
+ "tamil": "'Noto Sans Tamil', 'Lohit Tamil'",
36
+ "telugu": "'Noto Sans Telugu', 'Lohit Telugu'",
37
+ "kannada": "'Noto Sans Kannada', 'Lohit Kannada'",
38
+ "malayalam": "'Noto Sans Malayalam', 'Lohit Malayalam'",
39
+ "arabic": "'Noto Sans Arabic'",
40
+ "cjk": "'Noto Sans CJK SC'",
41
+ "gurmukhi": "'Noto Sans Gurmukhi'",
42
+ "odia": "'Noto Sans Oriya'",
43
+ }
44
+
45
+ EMOJI_REPLACEMENTS = {
46
+ '\u2705': '**\u2713**', '\u274c': '**\u2717**',
47
+ '\u26a0\ufe0f': '**!**', '\u26a0': '**!**',
48
+ '\u2b50': '\u2605', '\u2714\ufe0f': '**\u2713**', '\u2714': '**\u2713**',
49
+ '\u2718': '**\u2717**', '\u2753': '?',
50
+ '\U0001f4ca': '', '\U0001f4c4': '', '\U0001f4e6': '', '\U0001f50d': '', '\U0001f4dd': '',
51
+ '\U0001f6e0\ufe0f': '', '\U0001f6e0': '', '\u23f3': '', '\U0001f4e5': '', '\U0001f5bc\ufe0f': '',
52
+ '\U0001f5bc': '', '\U0001f333': '', '\u2699\ufe0f': '', '\u2699': '', '\U0001f504': '',
53
+ '\U0001f4a1': '', '\U0001f680': '', '\U0001f3af': '', '\U0001f512': '', '\U0001f511': '',
54
+ '\U0001f4c8': '', '\U0001f4c9': '', '\U0001f5c2\ufe0f': '', '\U0001f5c2': '', '\U0001f4cb': '',
55
+ '\U0001f527': '', '\U0001f4be': '', '\U0001f310': '', '\U0001f4e1': '', '\U0001f3d7\ufe0f': '',
56
+ '\U0001f3d7': '', '\U0001f3a8': '', '\U0001f4bb': '', '\U0001f5a5\ufe0f': '', '\U0001f5a5': '',
57
+ }
58
+
59
+
60
+ def detect_scripts(text):
61
+ found = set()
62
+ for char in text:
63
+ cp = ord(char)
64
+ for script, (lo, hi) in SCRIPT_RANGES.items():
65
+ if lo <= cp <= hi:
66
+ found.add(script)
67
+ return found
68
+
69
+
70
+ def build_font_stack(scripts):
71
+ parts = ["'Noto Sans'"]
72
+ for s in scripts:
73
+ if s in FONT_CSS_MAP:
74
+ parts.append(FONT_CSS_MAP[s])
75
+ parts.extend(["'Segoe UI'", "Arial", "sans-serif"])
76
+ return ", ".join(parts)
77
+
78
+
79
+ def process_emoji(md_text):
80
+ count = 0
81
+ for emoji, replacement in EMOJI_REPLACEMENTS.items():
82
+ n = md_text.count(emoji)
83
+ if n > 0:
84
+ md_text = md_text.replace(emoji, replacement)
85
+ count += n
86
+ return md_text, count
87
+
88
+
89
+ def convert_tree_to_mermaid(tree_code):
90
+ lines = tree_code.strip().split('\n')
91
+ graph_lines = ["graph TD"]
92
+ parent_at_indent = {}
93
+ for line in lines:
94
+ stripped = line.strip()
95
+ if not stripped or stripped == "tree":
96
+ continue
97
+ indent = len(line) - len(line.lstrip())
98
+ content = stripped
99
+ if content.startswith("-->"):
100
+ content = content[3:].strip()
101
+ node_match = re.match(r'(\w+)\["(.+?)"\]', content)
102
+ if node_match:
103
+ node_id = node_match.group(1)
104
+ label = node_match.group(2)
105
+ else:
106
+ node_id = re.sub(r'[^a-zA-Z0-9_]', '_', content)[:30]
107
+ label = content
108
+ parent_at_indent[indent] = node_id
109
+ parent_id = None
110
+ best_indent = -1
111
+ for lvl, nid in parent_at_indent.items():
112
+ if lvl < indent and lvl > best_indent:
113
+ best_indent = lvl
114
+ parent_id = nid
115
+ if parent_id:
116
+ graph_lines.append(f' {parent_id} --> {node_id}["{label}"]')
117
+ else:
118
+ graph_lines.append(f' {node_id}["{label}"]')
119
+ return '\n'.join(graph_lines)
120
+
121
+
122
+ def normalize_mermaid(code):
123
+ stripped = code.strip()
124
+ if stripped.startswith("tree"):
125
+ return convert_tree_to_mermaid(stripped)
126
+ if stripped.lower().startswith("mermaid"):
127
+ stripped = stripped[len("mermaid"):].strip()
128
+ return stripped
129
+
130
+
131
+ def render_mermaid_png(code, idx):
132
+ code = code.strip()
133
+ encoded = base64.urlsafe_b64encode(code.encode("utf-8")).decode("utf-8")
134
+ url = f"https://mermaid.ink/img/{encoded}?bgColor=white&width=1100"
135
+ try:
136
+ req = urllib.request.Request(url, headers={"User-Agent": "Md2Doc/1.0"})
137
+ with urllib.request.urlopen(req, timeout=45) as resp:
138
+ png = resp.read()
139
+ return base64.b64encode(png).decode("utf-8")
140
+ except Exception:
141
+ return None
142
+
143
+
144
+ def render_plantuml_png(code, idx):
145
+ try:
146
+ compressed = zlib.compress(code.encode('utf-8'), 9)
147
+ encoded = base64.urlsafe_b64encode(compressed).decode('utf-8')
148
+ url = f"https://kroki.io/plantuml/png/{encoded}"
149
+ req = urllib.request.Request(url, headers={"User-Agent": "Md2Doc/1.0"})
150
+ with urllib.request.urlopen(req, timeout=45) as resp:
151
+ png = resp.read()
152
+ return base64.b64encode(png).decode("utf-8")
153
+ except Exception:
154
+ return None
155
+
156
+
157
+ def is_ascii_art(code_text):
158
+ art_chars = set('\u2500\u2502\u250c\u2510\u2514\u2518\u251c\u2524\u252c\u2534\u253c\u2501\u2503\u250f\u2513\u2517\u251b\u2523\u252b\u2533\u253b\u254b\u2550\u2551\u2554\u2557\u255a\u255d\u2560\u2563\u2566\u2569\u256c\u25b2\u25bc\u25c4\u25ba\u2190\u2192\u2191\u2193')
159
+ total = len(code_text.replace(' ', '').replace('\n', ''))
160
+ if total == 0:
161
+ return False
162
+ art_count = sum(1 for c in code_text if c in art_chars)
163
+ return art_count / max(total, 1) > 0.05
164
+
165
+
166
+ def process_diagrams(md_text):
167
+ diagram_map = {}
168
+ diagram_count = [0]
169
+ def replace_block(match):
170
+ lang = match.group(1).strip().lower()
171
+ code = match.group(2)
172
+ diagram_count[0] += 1
173
+ idx = diagram_count[0]
174
+ placeholder = f"DIAGRAM_PLACEHOLDER_{idx}"
175
+ b64 = None
176
+ if lang == "mermaid":
177
+ normalized = normalize_mermaid(code)
178
+ b64 = render_mermaid_png(normalized, idx)
179
+ time.sleep(0.5)
180
+ elif lang == "plantuml":
181
+ b64 = render_plantuml_png(code, idx)
182
+ time.sleep(0.5)
183
+ diagram_map[placeholder] = {"lang": lang, "code": code.strip(), "b64": b64}
184
+ return f"\n{placeholder}\n"
185
+ pattern = re.compile(r'```(mermaid|plantuml)\s*\n(.*?)```', re.DOTALL)
186
+ md_text = pattern.sub(replace_block, md_text)
187
+ return md_text, diagram_map, diagram_count[0]
188
+
189
+
190
+ def process_math(md_text):
191
+ math_count = [0]
192
+ def replace_block_math(m):
193
+ math_count[0] += 1
194
+ content = m.group(1).strip()
195
+ escaped = content.replace('<', '&lt;').replace('>', '&gt;')
196
+ return f'\n<div class="math-block">{escaped}</div>\n'
197
+ def replace_inline_math(m):
198
+ math_count[0] += 1
199
+ content = m.group(1).strip()
200
+ escaped = content.replace('<', '&lt;').replace('>', '&gt;')
201
+ return f'<span class="math-inline">{escaped}</span>'
202
+ md_text = re.sub(r'\$\$(.+?)\$\$', replace_block_math, md_text, flags=re.DOTALL)
203
+ md_text = re.sub(r'(?<!\$)\$([^\$\n]+?)\$(?!\$)', replace_inline_math, md_text)
204
+ return md_text, math_count[0]
205
+
206
+
207
+ def strip_yaml_frontmatter(md_text):
208
+ pattern = re.compile(r'\A---\s*\n.*?\n---\s*\n', re.DOTALL)
209
+ m = pattern.match(md_text)
210
+ if m:
211
+ return md_text[m.end():], True
212
+ return md_text, False
213
+
214
+
215
+ def process_task_lists(md_text):
216
+ md_text = re.sub(r'- \[x\]', '- **\u2611**', md_text)
217
+ md_text = re.sub(r'- \[ \]', '- **\u2610**', md_text)
218
+ return md_text
219
+
220
+
221
+ def parse_markdown(md_text):
222
+ import markdown
223
+ html = markdown.markdown(
224
+ md_text,
225
+ extensions=[
226
+ "tables", "toc", "fenced_code", "codehilite",
227
+ "attr_list", "md_in_html", "sane_lists",
228
+ ],
229
+ extension_configs={
230
+ "codehilite": {"guess_lang": False, "css_class": "codehilite"},
231
+ },
232
+ )
233
+ return html
234
+
235
+
236
+ # ── HTML post-processing ──
237
+ from bs4 import BeautifulSoup, NavigableString
238
+
239
+
240
+ def inject_diagrams(soup, diagram_map):
241
+ for p_tag in soup.find_all("p"):
242
+ text = p_tag.get_text(strip=True)
243
+ if not text.startswith("DIAGRAM_PLACEHOLDER_"):
244
+ continue
245
+ info = diagram_map.get(text.strip())
246
+ if info is None:
247
+ continue
248
+ idx = text.split("_")[-1]
249
+ if info["b64"]:
250
+ img = soup.new_tag("img", src=f"data:image/png;base64,{info['b64']}", alt=f"Diagram {idx}")
251
+ wrapper = soup.new_tag("div", **{"class": "diagram-wrapper"})
252
+ wrapper.append(img)
253
+ p_tag.replace_with(wrapper)
254
+ else:
255
+ escaped = (info["code"].replace("&","&amp;").replace("<","&lt;").replace(">","&gt;"))
256
+ html = f'''<div class="diagram-fallback">
257
+ <div class="diagram-label">{info["lang"].title()} Diagram {idx}</div>
258
+ <pre class="diagram-code">{escaped}</pre>
259
+ </div>'''
260
+ p_tag.replace_with(BeautifulSoup(html, "html.parser"))
261
+
262
+
263
+ def style_status_indicators(soup):
264
+ for td in soup.find_all("td"):
265
+ cell_text = td.get_text(strip=True)
266
+ mapping = {'\u2713': 'status-yes', '\u2717': 'status-no', '!': 'status-warn',
267
+ '\u2611': 'status-yes', '\u2610': 'status-no'}
268
+ if cell_text in mapping:
269
+ td.clear()
270
+ span = soup.new_tag("span", **{"class": mapping[cell_text]})
271
+ span.string = cell_text
272
+ td.append(span)
273
+ elif cell_text.startswith('\u2713') and len(cell_text) > 1:
274
+ td.clear()
275
+ span = soup.new_tag("span", **{"class": "status-yes"})
276
+ span.string = "\u2713"
277
+ td.append(span)
278
+ td.append(f" {cell_text[1:].strip()}")
279
+
280
+
281
+ def fix_anchor_links(soup):
282
+ heading_map = {}
283
+ for tag in soup.find_all(["h1","h2","h3","h4","h5","h6"]):
284
+ if tag.get("id"):
285
+ slug = re.sub(r'[^a-z0-9]+', '-', tag.get_text().strip().lower()).strip('-')
286
+ heading_map[slug] = tag["id"]
287
+ for a in soup.find_all("a", href=True):
288
+ href = a["href"]
289
+ if href.startswith("#") and not soup.find(id=href[1:]):
290
+ clean = re.sub(r'[^a-z0-9]+', '-', href[1:].lower()).strip('-')
291
+ for ts, rid in heading_map.items():
292
+ if clean in ts or ts in clean:
293
+ a["href"] = f"#{rid}"
294
+ break
295
+
296
+
297
+ def classify_tables(soup):
298
+ tier_map = {(2, 3): None, (4, 4): "table-wide", (5, 6): "table-xwide",
299
+ (7, 8): "table-xxwide", (9, 99): "table-xxxwide"}
300
+ for table in soup.find_all("table"):
301
+ row = table.find("tr")
302
+ if not row:
303
+ continue
304
+ cols = len(row.find_all(["th", "td"]))
305
+ for (lo, hi), cls in tier_map.items():
306
+ if lo <= cols <= hi and cls:
307
+ table["class"] = table.get("class", []) + [cls]
308
+ break
309
+
310
+
311
+ def detect_signoff_tables(soup):
312
+ for table in soup.find_all("table"):
313
+ headers = [th.get_text(strip=True).lower() for th in table.find_all("th")]
314
+ if "signature" in headers:
315
+ table["class"] = table.get("class", []) + ["signoff-table"]
316
+
317
+
318
+ def remove_empty_elements(soup):
319
+ for p in soup.find_all("p"):
320
+ if not p.get_text(strip=True):
321
+ p.decompose()
322
+ for br in soup.find_all("br"):
323
+ nxt = br.next_sibling
324
+ if nxt and isinstance(nxt, NavigableString) and not nxt.strip():
325
+ nxt2 = nxt.next_sibling
326
+ if nxt2 and nxt2.name == "br":
327
+ br.decompose()
328
+
329
+
330
+ def get_heading_level(tag):
331
+ if tag and tag.name and re.match(r'h[1-6]$', tag.name):
332
+ return int(tag.name[1])
333
+ return 0
334
+
335
+
336
+ def protect_orphan_headings(soup):
337
+ for h_tag in soup.find_all(["h3", "h4", "h5"]):
338
+ if h_tag.find_parent(class_="keep-group"):
339
+ continue
340
+ if h_tag.find_parent(class_="cover-page"):
341
+ continue
342
+ h_level = get_heading_level(h_tag)
343
+ siblings = []
344
+ n = 0
345
+ nxt = h_tag.next_sibling
346
+ while nxt and n < 3:
347
+ if isinstance(nxt, NavigableString):
348
+ if nxt.strip():
349
+ break
350
+ nxt = nxt.next_sibling
351
+ continue
352
+ if get_heading_level(nxt) and get_heading_level(nxt) <= h_level:
353
+ break
354
+ siblings.append(nxt)
355
+ n += 1
356
+ nxt = nxt.next_sibling
357
+ if siblings:
358
+ wrapper = soup.new_tag("div", **{"class": "keep-group"})
359
+ h_tag.wrap(wrapper)
360
+ for sib in siblings:
361
+ wrapper.append(sib)
362
+
363
+
364
+ def detect_ascii_diagrams(soup):
365
+ for pre in soup.find_all("pre"):
366
+ code = pre.find("code")
367
+ text = (code or pre).get_text()
368
+ if is_ascii_art(text):
369
+ pre["class"] = pre.get("class", []) + ["ascii-diagram"]
370
+
371
+
372
+ def extract_section_table(soup, keyword):
373
+ for h3 in soup.find_all("h3"):
374
+ if keyword in h3.get_text():
375
+ parent = h3.parent
376
+ tbl = None
377
+ if parent and "keep-group" in (parent.get("class") or []):
378
+ tbl = parent.find("table")
379
+ if tbl:
380
+ tbl = tbl.extract()
381
+ parent.extract()
382
+ else:
383
+ nxt = h3.find_next_sibling()
384
+ if nxt and nxt.name == "table":
385
+ tbl = nxt.extract()
386
+ h3.extract()
387
+ return tbl
388
+ return None
389
+
390
+
391
+ def auto_detect_metadata(soup):
392
+ meta = {"doc_title": "", "doc_id": "", "org_name": "", "confidential": True}
393
+ h1 = soup.find("h1")
394
+ if h1:
395
+ meta["doc_title"] = h1.get_text(strip=True)
396
+ for table in soup.find_all("table"):
397
+ for row in table.find_all("tr"):
398
+ cells = row.find_all(["td", "th"])
399
+ if len(cells) >= 2:
400
+ key = cells[0].get_text(strip=True).lower()
401
+ val = cells[1].get_text(strip=True)
402
+ if "document id" in key:
403
+ meta["doc_id"] = val
404
+ elif "document title" in key and not meta["doc_title"]:
405
+ meta["doc_title"] = val
406
+ return meta
407
+
408
+
409
+ def build_cover_page(soup, metadata):
410
+ cover_parts = []
411
+ first_h1 = soup.find("h1")
412
+ if first_h1:
413
+ cover_parts.append(first_h1.extract())
414
+ first_h2 = soup.find("h2")
415
+ if first_h2:
416
+ text = first_h2.get_text(strip=True)
417
+ if len(text) > 40 or text.startswith("Preparation"):
418
+ cover_parts.append(first_h2.extract())
419
+ for el in list(soup.children):
420
+ if isinstance(el, NavigableString) and not el.strip():
421
+ continue
422
+ if el.name == "hr":
423
+ el.extract()
424
+ elif el.name in ["h1","h2","h3","h4"]:
425
+ break
426
+ doc_ctrl = extract_section_table(soup, "Document Control")
427
+ rev_hist = extract_section_table(soup, "Revision History")
428
+ dist_list = extract_section_table(soup, "Distribution List")
429
+ for el in soup.find_all(["h2","h3"]):
430
+ if "Table of Contents" in el.get_text():
431
+ parent = el.parent
432
+ if parent and "keep-group" in (parent.get("class") or []):
433
+ parent.extract()
434
+ else:
435
+ el.extract()
436
+ break
437
+ if not cover_parts:
438
+ return False
439
+ cover = soup.new_tag("div", **{"class": "cover-page"})
440
+ bar = soup.new_tag("div", **{"class": "cover-bar"})
441
+ cover.append(bar)
442
+ org_name = metadata.get("org_name", "")
443
+ if org_name:
444
+ org = soup.new_tag("p", **{"class": "cover-org"})
445
+ org.string = org_name
446
+ cover.append(org)
447
+ for part in cover_parts:
448
+ part["class"] = part.get("class", []) + ["cover-title"]
449
+ cover.append(part)
450
+ meta_tables = [("Document Control", doc_ctrl), ("Revision History", rev_hist), ("Distribution List", dist_list)]
451
+ for label, tbl in meta_tables:
452
+ if tbl:
453
+ lbl = soup.new_tag("h3", **{"class": "cover-section-label"})
454
+ lbl.string = label
455
+ w = soup.new_tag("div", **{"class": "cover-meta"})
456
+ w.append(lbl)
457
+ w.append(tbl)
458
+ cover.append(w)
459
+ if metadata.get("confidential"):
460
+ stamp = soup.new_tag("p", **{"class": "cover-confidential"})
461
+ stamp.string = "CONFIDENTIAL \u2014 For authorized personnel only"
462
+ cover.append(stamp)
463
+ if soup.contents:
464
+ soup.contents[0].insert_before(cover)
465
+ else:
466
+ soup.append(cover)
467
+ return True
468
+
469
+
470
+ def add_page_breaks(soup, has_cover):
471
+ h2_list = [h2 for h2 in soup.find_all("h2") if not h2.find_parent(class_="cover-page")]
472
+ for i, h2 in enumerate(h2_list):
473
+ if i == 0 and has_cover:
474
+ continue
475
+ h2["class"] = h2.get("class", []) + ["page-break-before"]
476
+ for el in list(soup.children):
477
+ if isinstance(el, NavigableString) and not el.strip():
478
+ continue
479
+ if el.name == "hr":
480
+ el.extract()
481
+ elif hasattr(el, "get") and "cover-page" in (el.get("class") or []):
482
+ break
483
+ else:
484
+ break
485
+
486
+
487
+ # ── CSS ──
488
+ def build_css(font_stack, metadata):
489
+ header = metadata.get("header_text", metadata.get("doc_title", ""))
490
+ doc_id = metadata.get("doc_id", "")
491
+ conf = "Confidential" if metadata.get("confidential") else ""
492
+ return f"""
493
+ @page {{ size: A4 portrait; margin: 18mm 16mm 20mm 16mm;
494
+ @top-center {{ content: "{header}"; font-size: 7pt; color: #6b7280;
495
+ font-family: {font_stack}; border-bottom: 0.4pt solid #d1d5db; padding-bottom: 3pt; }}
496
+ @bottom-center {{ content: "Page " counter(page) " of " counter(pages);
497
+ font-size: 7pt; color: #6b7280; font-family: {font_stack}; }}
498
+ @bottom-left {{ content: "{conf}"; font-size: 6.5pt; color: #9ca3af; }}
499
+ @bottom-right {{ content: "Document ID: {doc_id}"; font-size: 6.5pt; color: #9ca3af; }}
500
+ }}
501
+ @page :first {{ margin-top: 12mm;
502
+ @top-center {{ content: none; }} @bottom-left {{ content: none; }}
503
+ @bottom-right {{ content: none; }} @bottom-center {{ content: none; }}
504
+ }}
505
+ body {{ font-family: {font_stack}; font-size: 9.5pt; line-height: 1.5; color: #1f2937; orphans: 3; widows: 3; }}
506
+ .status-yes {{ color:#16a34a; font-weight:700; font-size:11pt; }}
507
+ .status-no {{ color:#dc2626; font-weight:700; font-size:11pt; }}
508
+ .status-warn {{ color:#d97706; font-weight:700; font-size:11pt;
509
+ background:#fef3c7; border-radius:50%; width:14pt; height:14pt; line-height:14pt; text-align:center; }}
510
+ .cover-page {{ page-break-after:always; text-align:center; padding-top:18pt; }}
511
+ .cover-bar {{ width:60%; height:4px; margin:0 auto 14px; border-radius:2px;
512
+ background:linear-gradient(90deg,#1e3a5f,#2563eb,#1e3a5f); }}
513
+ .cover-org {{ font-size:11pt; color:#1e3a5f; font-weight:600; letter-spacing:0.8px;
514
+ text-transform:uppercase; margin-bottom:14px; border-bottom:1.5px solid #2563eb;
515
+ padding-bottom:6px; display:inline-block; }}
516
+ .cover-page .cover-title {{ page-break-before:avoid!important; page-break-after:avoid!important;
517
+ border-bottom:none!important; margin-top:2px; margin-bottom:2px; }}
518
+ .cover-page h1.cover-title {{ font-size:20pt; color:#1e3a5f; line-height:1.25; margin-bottom:4px; padding-bottom:0; }}
519
+ .cover-page h2.cover-title {{ font-size:9.5pt; font-weight:400; color:#374151;
520
+ line-height:1.45; max-width:85%; margin:0 auto 10px; padding-bottom:0; }}
521
+ .cover-meta {{ margin-top:6px; width:88%; margin-left:auto; margin-right:auto; text-align:left; }}
522
+ .cover-section-label {{ font-size:8.5pt; color:#1e3a5f; font-weight:600;
523
+ margin-bottom:2px; margin-top:6px; text-align:left; border-bottom:none!important; page-break-after:avoid; }}
524
+ .cover-meta table {{ font-size:7.5pt!important; table-layout:auto!important; margin:0 auto 2px; }}
525
+ .cover-meta table th {{ background:#1e3a5f; color:#fff; font-size:7pt; padding:3px 4px; }}
526
+ .cover-meta table td {{ font-size:7.5pt; padding:3px 4px; }}
527
+ .cover-confidential {{ margin-top:12px; font-size:7.5pt; color:#dc2626; font-weight:600;
528
+ letter-spacing:0.4px; border:1.2px solid #dc2626; padding:4px 14px; border-radius:3px; display:inline-block; }}
529
+ h1 {{ font-size:18pt; color:#1e3a5f; border-bottom:2.5px solid #2563eb; padding-bottom:5px;
530
+ margin-top:16px; margin-bottom:8px; page-break-after:avoid; }}
531
+ h2 {{ font-size:14pt; color:#1e3a5f; border-bottom:1.5px solid #3b82f6; padding-bottom:4px;
532
+ margin-top:10px; margin-bottom:6px; page-break-after:avoid; }}
533
+ h3 {{ font-size:12pt; color:#1e40af; margin-top:10px; margin-bottom:4px; page-break-after:avoid; }}
534
+ h4 {{ font-size:10.5pt; color:#1d4ed8; margin-top:8px; margin-bottom:3px; page-break-after:avoid; }}
535
+ h5 {{ font-size:9.5pt; color:#1e40af; margin-top:6px; margin-bottom:2px; page-break-after:avoid; }}
536
+ h6 {{ font-size:9pt; color:#374151; margin-top:5px; margin-bottom:2px; page-break-after:avoid; }}
537
+ .page-break-before {{ page-break-before:always; }}
538
+ .keep-group {{ page-break-inside:avoid; }}
539
+ p {{ margin:3px 0; text-align:justify; }}
540
+ strong {{ color:#111827; }}
541
+ a {{ color:#2563eb; text-decoration:none; }}
542
+ ul, ol {{ margin:3px 0 3px 18px; padding-left:8px; }}
543
+ li {{ margin-bottom:1.5px; }}
544
+ li > ul, li > ol {{ margin-top:1px; margin-bottom:1px; }}
545
+ blockquote {{ border-left:3px solid #f59e0b; background:#fffbeb; padding:5px 10px;
546
+ margin:5px 0; font-style:italic; color:#92400e; font-size:9pt; }}
547
+ hr {{ border:none; height:1.5px; margin:10px 0; background:linear-gradient(to right,#2563eb,#93c5fd,#2563eb); }}
548
+ table {{ width:100%; border-collapse:collapse; margin:6px 0 8px; font-size:8pt;
549
+ line-height:1.35; table-layout:fixed; word-wrap:break-word; overflow-wrap:break-word; page-break-inside:auto; }}
550
+ thead {{ display:table-header-group; }}
551
+ tr {{ page-break-inside:avoid; }}
552
+ th {{ background:#1e3a5f; color:#fff; font-weight:600; text-align:left; padding:5px;
553
+ border:1px solid #1e3a5f; font-size:7.5pt; letter-spacing:0.2px; }}
554
+ td {{ padding:4px 5px; border:1px solid #d1d5db; vertical-align:top; }}
555
+ tbody tr:nth-child(even) {{ background:#f0f4ff; }}
556
+ tbody tr:nth-child(odd) {{ background:#fff; }}
557
+ table.table-wide {{ font-size:7.5pt; }}
558
+ table.table-wide th, table.table-wide td {{ padding:3px 4px; }}
559
+ table.table-xwide {{ font-size:7pt; }}
560
+ table.table-xwide th, table.table-xwide td {{ padding:2.5px 3px; }}
561
+ table.table-xxwide {{ font-size:6.5pt; }}
562
+ table.table-xxwide th, table.table-xxwide td {{ padding:2px 2.5px; }}
563
+ table.table-xxxwide {{ font-size:6pt; }}
564
+ table.table-xxxwide th, table.table-xxxwide td {{ padding:1.5px 2px; }}
565
+ table.signoff-table td {{ min-height:30pt; height:30pt; }}
566
+ pre {{ background:#f8fafc; border:1px solid #cbd5e1; border-left:3px solid #2563eb;
567
+ border-radius:3px; padding:6px 10px; font-family:'Consolas','Courier New',monospace;
568
+ font-size:6.5pt; line-height:1.25; overflow-wrap:break-word; white-space:pre-wrap;
569
+ page-break-inside:avoid; margin:4px 0; }}
570
+ code {{ font-family:'Consolas','Courier New',monospace; font-size:7.5pt;
571
+ background:#eef2ff; padding:0.5px 3px; border-radius:2px; color:#4338ca; }}
572
+ pre code {{ background:none; padding:0; color:#334155; font-size:6.5pt; }}
573
+ pre.ascii-diagram {{ border-left:3px solid #6366f1; background:#faf5ff; }}
574
+ .diagram-wrapper {{ margin:8px auto; text-align:center; page-break-inside:avoid; }}
575
+ .diagram-wrapper img {{ max-width:100%; max-height:580pt; width:auto; height:auto;
576
+ display:block; margin:0 auto; border:1px solid #e2e8f0; border-radius:4px;
577
+ box-shadow:0 1px 3px rgba(0,0,0,0.06); }}
578
+ .diagram-fallback {{ margin:6px 0; border:1.2px solid #3b82f6; border-radius:4px;
579
+ overflow:hidden; page-break-inside:avoid; }}
580
+ .diagram-label {{ background:#1e3a5f; color:#fff; font-size:7.5pt; font-weight:600; padding:3px 8px; }}
581
+ .diagram-code {{ background:#f8fafc; border:none; border-left:none; border-radius:0;
582
+ margin:0; font-size:6pt; line-height:1.25; padding:5px 8px; color:#1e293b; }}
583
+ .math-inline {{ font-family:'Cambria Math','Times New Roman',serif; font-style:italic;
584
+ color:#7c3aed; background:#f5f3ff; padding:1px 4px; border-radius:2px; }}
585
+ .math-block {{ font-family:'Cambria Math','Times New Roman',serif; font-style:italic;
586
+ color:#7c3aed; background:#f5f3ff; padding:8px 16px; margin:6px 0; border-radius:4px;
587
+ text-align:center; font-size:11pt; page-break-inside:avoid; }}
588
+ p > strong:first-child {{ color:#1e40af; }}
589
+ .cover-page + * {{ page-break-before:avoid!important; }}
590
+ """
591
+
592
+
593
+ def generate_pdf(soup, metadata, font_stack, output_path):
594
+ from weasyprint import HTML as WeasyHTML
595
+ css = build_css(font_stack, metadata)
596
+ title = metadata.get("doc_title", "Document")
597
+ full_html = f"""<!DOCTYPE html>
598
+ <html lang="en"><head><meta charset="UTF-8"><title>{title}</title>
599
+ <style>{css}</style></head><body>{str(soup)}</body></html>"""
600
+ full_html = re.sub(r'\n{4,}', '\n\n', full_html)
601
+ WeasyHTML(string=full_html).write_pdf(output_path)
602
+ return output_path
603
+
604
+
605
+ # ── DOCX generation ──
606
+ def generate_docx(soup, metadata, output_path):
607
+ from docx import Document
608
+ from docx.shared import Pt, Inches, Cm, RGBColor
609
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
610
+ from docx.oxml.ns import qn
611
+ from docx.oxml import OxmlElement
612
+
613
+ doc = Document()
614
+ section = doc.sections[0]
615
+ section.page_width = Cm(21.0)
616
+ section.page_height = Cm(29.7)
617
+ section.top_margin = Cm(1.8)
618
+ section.bottom_margin = Cm(2.0)
619
+ section.left_margin = Cm(1.6)
620
+ section.right_margin = Cm(1.6)
621
+
622
+ header = section.header
623
+ header.is_linked_to_previous = False
624
+ hp = header.paragraphs[0]
625
+ hp.alignment = WD_ALIGN_PARAGRAPH.CENTER
626
+ hr = hp.add_run(metadata.get("header_text", metadata.get("doc_title", "")))
627
+ hr.font.size = Pt(7)
628
+ hr.font.color.rgb = RGBColor(0x6B, 0x72, 0x80)
629
+ pPr = hp._p.get_or_add_pPr()
630
+ pBdr = OxmlElement('w:pBdr')
631
+ bot = OxmlElement('w:bottom')
632
+ bot.set(qn('w:val'), 'single')
633
+ bot.set(qn('w:sz'), '2')
634
+ bot.set(qn('w:color'), 'D1D5DB')
635
+ pBdr.append(bot)
636
+ pPr.append(pBdr)
637
+
638
+ footer = section.footer
639
+ footer.is_linked_to_previous = False
640
+ fp = footer.paragraphs[0]
641
+ fp.alignment = WD_ALIGN_PARAGRAPH.CENTER
642
+ fr = fp.add_run("Page ")
643
+ fr.font.size = Pt(7)
644
+ fr.font.color.rgb = RGBColor(0x6B, 0x72, 0x80)
645
+ run = fp.add_run()
646
+ fld1 = OxmlElement('w:fldChar')
647
+ fld1.set(qn('w:fldCharType'), 'begin')
648
+ run._r.append(fld1)
649
+ instr = OxmlElement('w:instrText')
650
+ instr.set(qn('xml:space'), 'preserve')
651
+ instr.text = ' PAGE '
652
+ run._r.append(instr)
653
+ fld2 = OxmlElement('w:fldChar')
654
+ fld2.set(qn('w:fldCharType'), 'end')
655
+ run._r.append(fld2)
656
+ run.font.size = Pt(7)
657
+ run.font.color.rgb = RGBColor(0x6B, 0x72, 0x80)
658
+
659
+ def shade_cell(cell, color_hex):
660
+ shd = OxmlElement('w:shd')
661
+ shd.set(qn('w:fill'), color_hex)
662
+ shd.set(qn('w:val'), 'clear')
663
+ cell._tc.get_or_add_tcPr().append(shd)
664
+
665
+ def add_runs(paragraph, element):
666
+ for child in element.children:
667
+ if isinstance(child, NavigableString):
668
+ text = str(child)
669
+ if text.strip() or text == ' ':
670
+ paragraph.add_run(text)
671
+ elif child.name in ('strong', 'b'):
672
+ r = paragraph.add_run(child.get_text())
673
+ r.bold = True
674
+ elif child.name in ('em', 'i'):
675
+ r = paragraph.add_run(child.get_text())
676
+ r.italic = True
677
+ elif child.name == 'code':
678
+ r = paragraph.add_run(child.get_text())
679
+ r.font.name = 'Consolas'
680
+ r.font.size = Pt(8)
681
+ r.font.color.rgb = RGBColor(0x43, 0x38, 0xCA)
682
+ elif child.name == 'a':
683
+ r = paragraph.add_run(child.get_text())
684
+ r.font.color.rgb = RGBColor(0x25, 0x63, 0xEB)
685
+ elif child.name == 'span':
686
+ cls = child.get('class', [])
687
+ r = paragraph.add_run(child.get_text())
688
+ if 'status-yes' in cls:
689
+ r.font.color.rgb = RGBColor(0x16, 0xA3, 0x4A); r.bold = True
690
+ elif 'status-no' in cls:
691
+ r.font.color.rgb = RGBColor(0xDC, 0x26, 0x26); r.bold = True
692
+ elif 'status-warn' in cls:
693
+ r.font.color.rgb = RGBColor(0xD9, 0x77, 0x06); r.bold = True
694
+ elif 'math-inline' in cls:
695
+ r.font.name = 'Cambria Math'; r.italic = True
696
+ elif child.name == 'br':
697
+ paragraph.add_run('\n')
698
+ else:
699
+ text = child.get_text()
700
+ if text.strip():
701
+ paragraph.add_run(text)
702
+
703
+ def add_table(element):
704
+ rows = element.find_all('tr')
705
+ if not rows:
706
+ return
707
+ first_cells = rows[0].find_all(['th', 'td'])
708
+ ncols = len(first_cells)
709
+ if ncols == 0:
710
+ return
711
+ tbl = doc.add_table(rows=0, cols=ncols)
712
+ tbl.style = 'Table Grid'
713
+ tbl.autofit = True
714
+ for ri, row in enumerate(rows):
715
+ cells = row.find_all(['th', 'td'])
716
+ docx_row = tbl.add_row()
717
+ for ci, cell in enumerate(cells):
718
+ if ci >= ncols:
719
+ break
720
+ docx_cell = docx_row.cells[ci]
721
+ docx_cell.paragraphs[0].clear()
722
+ para = docx_cell.paragraphs[0]
723
+ para.paragraph_format.space_after = Pt(0)
724
+ para.paragraph_format.space_before = Pt(0)
725
+ add_runs(para, cell)
726
+ if cell.name == 'th':
727
+ shade_cell(docx_cell, '1E3A5F')
728
+ for r in para.runs:
729
+ r.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
730
+ r.bold = True; r.font.size = Pt(8)
731
+ elif ri % 2 == 0 and ri > 0:
732
+ shade_cell(docx_cell, 'F0F4FF')
733
+ for r in para.runs:
734
+ if r.font.size is None:
735
+ r.font.size = Pt(8)
736
+ doc.add_paragraph()
737
+
738
+ def add_list(element, ordered=False, level=0):
739
+ style = 'List Number' if ordered else 'List Bullet'
740
+ for li in element.find_all('li', recursive=False):
741
+ para = doc.add_paragraph(style=style)
742
+ para.paragraph_format.left_indent = Cm(1.0 + level * 0.6)
743
+ para.paragraph_format.space_after = Pt(1)
744
+ add_runs(para, li)
745
+ for sub in li.find_all(['ul', 'ol'], recursive=False):
746
+ add_list(sub, ordered=(sub.name == 'ol'), level=level+1)
747
+
748
+ def add_image(img_tag):
749
+ src = img_tag.get('src', '')
750
+ if src.startswith('data:image'):
751
+ try:
752
+ b64_data = src.split(',', 1)[1]
753
+ img_bytes = base64.b64decode(b64_data)
754
+ stream = BytesIO(img_bytes)
755
+ doc.add_picture(stream, width=Inches(5.5))
756
+ doc.paragraphs[-1].alignment = WD_ALIGN_PARAGRAPH.CENTER
757
+ except Exception:
758
+ doc.add_paragraph("[Image could not be embedded]")
759
+
760
+ h2_count = 0
761
+
762
+ def walk(parent):
763
+ nonlocal h2_count
764
+ for el in parent.children:
765
+ if isinstance(el, NavigableString):
766
+ text = str(el).strip()
767
+ if text:
768
+ doc.add_paragraph(text)
769
+ continue
770
+ classes = el.get('class', [])
771
+ if 'cover-page' in classes:
772
+ for part in el.find_all(class_='cover-title'):
773
+ if part.name == 'h1':
774
+ h = doc.add_heading(level=0)
775
+ h.alignment = WD_ALIGN_PARAGRAPH.CENTER
776
+ r = h.add_run(part.get_text())
777
+ r.font.size = Pt(20)
778
+ r.font.color.rgb = RGBColor(0x1E, 0x3A, 0x5F)
779
+ elif part.name == 'h2':
780
+ p = doc.add_paragraph()
781
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
782
+ r = p.add_run(part.get_text())
783
+ r.font.size = Pt(10)
784
+ r.font.color.rgb = RGBColor(0x37, 0x41, 0x51)
785
+ org_el = el.find(class_='cover-org')
786
+ if org_el:
787
+ p = doc.add_paragraph()
788
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
789
+ r = p.add_run(org_el.get_text())
790
+ r.bold = True; r.font.size = Pt(11)
791
+ r.font.color.rgb = RGBColor(0x1E, 0x3A, 0x5F)
792
+ for meta_div in el.find_all(class_='cover-meta'):
793
+ tbl_el = meta_div.find('table')
794
+ lbl = meta_div.find(class_='cover-section-label')
795
+ if lbl:
796
+ p = doc.add_paragraph()
797
+ r = p.add_run(lbl.get_text())
798
+ r.bold = True; r.font.size = Pt(9)
799
+ if tbl_el:
800
+ add_table(tbl_el)
801
+ conf = el.find(class_='cover-confidential')
802
+ if conf:
803
+ p = doc.add_paragraph()
804
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
805
+ r = p.add_run(conf.get_text())
806
+ r.bold = True; r.font.size = Pt(8)
807
+ r.font.color.rgb = RGBColor(0xDC, 0x26, 0x26)
808
+ doc.add_page_break()
809
+ continue
810
+ if el.name and re.match(r'h[1-6]$', el.name):
811
+ level = int(el.name[1])
812
+ if level == 2:
813
+ h2_count += 1
814
+ if h2_count > 1:
815
+ doc.add_page_break()
816
+ h = doc.add_heading(level=min(level, 9))
817
+ add_runs(h, el)
818
+ continue
819
+ if el.name == 'p':
820
+ para = doc.add_paragraph()
821
+ add_runs(para, el)
822
+ continue
823
+ if el.name == 'table':
824
+ add_table(el)
825
+ continue
826
+ if el.name == 'pre':
827
+ code_text = el.get_text()
828
+ para = doc.add_paragraph()
829
+ r = para.add_run(code_text)
830
+ r.font.name = 'Consolas'; r.font.size = Pt(7)
831
+ pPr = para._p.get_or_add_pPr()
832
+ shd = OxmlElement('w:shd')
833
+ shd.set(qn('w:fill'), 'F8FAFC')
834
+ shd.set(qn('w:val'), 'clear')
835
+ pPr.append(shd)
836
+ continue
837
+ if el.name == 'ul':
838
+ add_list(el, ordered=False); continue
839
+ if el.name == 'ol':
840
+ add_list(el, ordered=True); continue
841
+ if el.name == 'blockquote':
842
+ para = doc.add_paragraph()
843
+ para.paragraph_format.left_indent = Cm(1.0)
844
+ r = para.add_run(el.get_text())
845
+ r.italic = True
846
+ r.font.color.rgb = RGBColor(0x92, 0x40, 0x0E)
847
+ r.font.size = Pt(9)
848
+ continue
849
+ if el.name == 'hr':
850
+ para = doc.add_paragraph()
851
+ pPr = para._p.get_or_add_pPr()
852
+ pBdr = OxmlElement('w:pBdr')
853
+ b = OxmlElement('w:bottom')
854
+ b.set(qn('w:val'), 'single')
855
+ b.set(qn('w:sz'), '4')
856
+ b.set(qn('w:color'), '3B82F6')
857
+ pBdr.append(b)
858
+ pPr.append(pBdr)
859
+ continue
860
+ if el.name == 'div' and 'math-block' in classes:
861
+ para = doc.add_paragraph()
862
+ para.alignment = WD_ALIGN_PARAGRAPH.CENTER
863
+ r = para.add_run(el.get_text())
864
+ r.font.name = 'Cambria Math'; r.italic = True; r.font.size = Pt(11)
865
+ continue
866
+ if 'diagram-wrapper' in classes:
867
+ img = el.find('img')
868
+ if img:
869
+ add_image(img)
870
+ continue
871
+ if 'diagram-fallback' in classes:
872
+ pre = el.find('pre')
873
+ if pre:
874
+ para = doc.add_paragraph()
875
+ r = para.add_run(pre.get_text())
876
+ r.font.name = 'Consolas'; r.font.size = Pt(6.5)
877
+ continue
878
+ if el.name in ['div', 'section', 'article', 'main']:
879
+ walk(el); continue
880
+ if el.name:
881
+ text = el.get_text(strip=True)
882
+ if text:
883
+ doc.add_paragraph(text)
884
+
885
+ walk(soup)
886
+ doc.save(output_path)
887
+ return output_path
888
+
889
+
890
+ # ══════════════════════════════════════════════════════════════
891
+ # Main convert function with progress callback
892
+ # ══════════════════════════════════════════════════════════════
893
+
894
+ class ConversionJob:
895
+ def __init__(self):
896
+ self.status = "pending"
897
+ self.percent = 0
898
+ self.steps = []
899
+ self.results = []
900
+ self.error = None
901
+ self._lock = threading.Lock()
902
+
903
+ def update(self, percent, step, detail=""):
904
+ with self._lock:
905
+ self.percent = percent
906
+ self.steps.append({"step": step, "detail": detail})
907
+
908
+ def get_state(self):
909
+ with self._lock:
910
+ return {
911
+ "status": self.status,
912
+ "percent": self.percent,
913
+ "steps": list(self.steps),
914
+ "results": list(self.results),
915
+ "error": self.error,
916
+ }
917
+
918
+ def complete(self, results):
919
+ with self._lock:
920
+ self.status = "complete"
921
+ self.percent = 100
922
+ self.results = results
923
+
924
+ def fail(self, error):
925
+ with self._lock:
926
+ self.status = "error"
927
+ self.error = str(error)
928
+
929
+
930
+ def run_conversion(job: ConversionJob, input_path: str, output_format: str,
931
+ org_name: str, confidential: bool):
932
+ try:
933
+ job.status = "running"
934
+ stem = Path(input_path).stem
935
+ parent = str(Path(input_path).parent)
936
+ want_pdf = output_format in ("pdf", "both")
937
+ want_docx = output_format in ("docx", "both")
938
+ pdf_path = os.path.join(parent, f"{stem}.pdf")
939
+ docx_path = os.path.join(parent, f"{stem}.docx")
940
+
941
+ # Step 1: Read file
942
+ job.update(5, "Reading markdown file...")
943
+ with open(input_path, "r", encoding="utf-8") as f:
944
+ md_text = f.read()
945
+ job.update(10, "File loaded", f"{len(md_text):,} characters")
946
+
947
+ # Step 2: Detect scripts
948
+ job.update(12, "Detecting languages...")
949
+ scripts = detect_scripts(md_text)
950
+ if scripts:
951
+ job.update(15, "Scripts detected", ", ".join(sorted(scripts)))
952
+ else:
953
+ job.update(15, "Language detection complete", "Latin only")
954
+ font_stack = build_font_stack(scripts)
955
+
956
+ # Step 3: Pre-process
957
+ job.update(18, "Stripping YAML frontmatter...")
958
+ md_text, had_yaml = strip_yaml_frontmatter(md_text)
959
+
960
+ job.update(22, "Processing emoji...")
961
+ md_text, emoji_count = process_emoji(md_text)
962
+ if emoji_count:
963
+ job.update(25, "Emoji processed", f"{emoji_count} replacements")
964
+
965
+ job.update(28, "Processing task lists...")
966
+ md_text = process_task_lists(md_text)
967
+
968
+ job.update(30, "Processing math expressions...")
969
+ md_text, math_count = process_math(md_text)
970
+ if math_count:
971
+ job.update(33, "Math processed", f"{math_count} expressions")
972
+
973
+ job.update(35, "Rendering diagrams...")
974
+ md_text, diagram_map, diag_count = process_diagrams(md_text)
975
+ if diag_count:
976
+ job.update(42, "Diagrams rendered", f"{diag_count} diagram(s)")
977
+ else:
978
+ job.update(42, "No diagrams found")
979
+
980
+ # Step 4: Parse markdown
981
+ job.update(45, "Parsing markdown to HTML...")
982
+ html_body = parse_markdown(md_text)
983
+ job.update(50, "HTML generated")
984
+
985
+ # Step 5: Metadata
986
+ job.update(52, "Detecting document metadata...")
987
+ temp_soup = BeautifulSoup(html_body, "html.parser")
988
+ metadata = auto_detect_metadata(temp_soup)
989
+ if org_name:
990
+ metadata["org_name"] = org_name
991
+ elif not metadata.get("org_name"):
992
+ metadata["org_name"] = ""
993
+ metadata["confidential"] = confidential
994
+ metadata["header_text"] = metadata.get("doc_title", stem)
995
+ job.update(55, "Metadata ready",
996
+ f"Title: {metadata.get('doc_title', 'N/A')[:50]}")
997
+
998
+ # Step 6: Post-process HTML
999
+ job.update(58, "Post-processing HTML...")
1000
+ soup = BeautifulSoup(html_body, "html.parser")
1001
+ inject_diagrams(soup, diagram_map)
1002
+ job.update(60, "Diagrams injected")
1003
+ style_status_indicators(soup)
1004
+ fix_anchor_links(soup)
1005
+ classify_tables(soup)
1006
+ detect_signoff_tables(soup)
1007
+ detect_ascii_diagrams(soup)
1008
+ remove_empty_elements(soup)
1009
+ protect_orphan_headings(soup)
1010
+ job.update(65, "HTML post-processing complete")
1011
+
1012
+ has_cover = build_cover_page(soup, metadata)
1013
+ add_page_breaks(soup, has_cover)
1014
+ job.update(68, "Cover page built" if has_cover else "No cover page needed")
1015
+
1016
+ # Step 7: Generate outputs
1017
+ results = []
1018
+ if want_pdf:
1019
+ job.update(70, "Generating PDF...", "This may take a moment")
1020
+ generate_pdf(soup, metadata, font_stack, pdf_path)
1021
+ size = os.path.getsize(pdf_path)
1022
+ results.append({"name": f"{stem}.pdf", "path": pdf_path,
1023
+ "size": f"{size/1024:.0f} KB", "type": "pdf"})
1024
+ job.update(85, "PDF generated", f"{size/1024:.0f} KB")
1025
+
1026
+ if want_docx:
1027
+ job.update(87, "Generating DOCX...", "Building document structure")
1028
+ generate_docx(soup, metadata, docx_path)
1029
+ size = os.path.getsize(docx_path)
1030
+ results.append({"name": f"{stem}.docx", "path": docx_path,
1031
+ "size": f"{size/1024:.0f} KB", "type": "docx"})
1032
+ job.update(97, "DOCX generated", f"{size/1024:.0f} KB")
1033
+
1034
+ job.complete(results)
1035
+
1036
+ except Exception as e:
1037
+ import traceback
1038
+ traceback.print_exc()
1039
+ job.fail(str(e))
app/main.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ import os
3
+ import asyncio
4
+ import json
5
+ import threading
6
+ import shutil
7
+ from pathlib import Path
8
+
9
+ from fastapi import FastAPI, UploadFile, File, Form, Request
10
+ from fastapi.responses import HTMLResponse, FileResponse, StreamingResponse
11
+ from fastapi.templating import Jinja2Templates
12
+
13
+ from app.converter import ConversionJob, run_conversion
14
+
15
+ app = FastAPI(title="MD to Doc", description="Universal Markdown to PDF/DOCX Converter")
16
+
17
+ BASE_DIR = Path(__file__).resolve().parent
18
+ templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
19
+
20
+ UPLOAD_DIR = Path("/tmp/mdtodoc")
21
+ UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
22
+
23
+ # In-memory stores
24
+ uploaded_files: dict = {} # file_id -> {filename, path, size, job_dir}
25
+ conversion_jobs: dict = {} # job_id -> ConversionJob
26
+
27
+
28
+ @app.get("/", response_class=HTMLResponse)
29
+ async def index(request: Request):
30
+ return templates.TemplateResponse("index.html", {"request": request})
31
+
32
+
33
+ @app.post("/api/upload")
34
+ async def upload_file(file: UploadFile = File(...)):
35
+ if not file.filename.endswith(".md"):
36
+ return {"error": "Only .md files are supported"}
37
+
38
+ file_id = str(uuid.uuid4())[:8]
39
+ job_dir = UPLOAD_DIR / file_id
40
+ job_dir.mkdir(parents=True, exist_ok=True)
41
+
42
+ file_path = job_dir / file.filename
43
+ content = await file.read()
44
+ with open(file_path, "wb") as f:
45
+ f.write(content)
46
+
47
+ size = len(content)
48
+ uploaded_files[file_id] = {
49
+ "filename": file.filename,
50
+ "path": str(file_path),
51
+ "size": size,
52
+ "job_dir": str(job_dir),
53
+ }
54
+
55
+ size_str = f"{size / 1024:.1f} KB" if size < 1024 * 1024 else f"{size / (1024*1024):.1f} MB"
56
+ return {"id": file_id, "filename": file.filename, "size": size_str}
57
+
58
+
59
+ @app.delete("/api/files/{file_id}")
60
+ async def delete_file(file_id: str):
61
+ info = uploaded_files.pop(file_id, None)
62
+ if info:
63
+ job_dir = info.get("job_dir")
64
+ if job_dir and os.path.exists(job_dir):
65
+ shutil.rmtree(job_dir, ignore_errors=True)
66
+ return {"ok": True}
67
+
68
+
69
+ @app.post("/api/convert")
70
+ async def start_conversion(
71
+ file_id: str = Form(...),
72
+ format: str = Form("both"),
73
+ org_name: str = Form(""),
74
+ confidential: bool = Form(True),
75
+ ):
76
+ info = uploaded_files.get(file_id)
77
+ if not info:
78
+ return {"error": "File not found. Please re-upload."}
79
+
80
+ job_id = file_id
81
+ job = ConversionJob()
82
+ conversion_jobs[job_id] = job
83
+
84
+ thread = threading.Thread(
85
+ target=run_conversion,
86
+ args=(job, info["path"], format, org_name, confidential),
87
+ daemon=True,
88
+ )
89
+ thread.start()
90
+
91
+ return {"job_id": job_id}
92
+
93
+
94
+ @app.get("/api/progress/{job_id}")
95
+ async def progress_stream(job_id: str):
96
+ job = conversion_jobs.get(job_id)
97
+ if not job:
98
+ return StreamingResponse(
99
+ iter([f"data: {json.dumps({'error': 'Job not found'})}\n\n"]),
100
+ media_type="text/event-stream",
101
+ )
102
+
103
+ async def event_generator():
104
+ last_step_count = 0
105
+ while True:
106
+ state = job.get_state()
107
+ current_steps = state["steps"]
108
+
109
+ # Send any new steps
110
+ if len(current_steps) > last_step_count:
111
+ for step in current_steps[last_step_count:]:
112
+ event = {
113
+ "type": "progress",
114
+ "percent": state["percent"],
115
+ "step": step["step"],
116
+ "detail": step.get("detail", ""),
117
+ }
118
+ yield f"data: {json.dumps(event)}\n\n"
119
+ last_step_count = len(current_steps)
120
+
121
+ if state["status"] == "complete":
122
+ event = {
123
+ "type": "complete",
124
+ "percent": 100,
125
+ "results": state["results"],
126
+ }
127
+ yield f"data: {json.dumps(event)}\n\n"
128
+ break
129
+ elif state["status"] == "error":
130
+ event = {
131
+ "type": "error",
132
+ "message": state["error"],
133
+ }
134
+ yield f"data: {json.dumps(event)}\n\n"
135
+ break
136
+
137
+ await asyncio.sleep(0.3)
138
+
139
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
140
+
141
+
142
+ @app.get("/api/download/{job_id}/{filename}")
143
+ async def download_file(job_id: str, filename: str):
144
+ info = uploaded_files.get(job_id)
145
+ if not info:
146
+ return {"error": "File not found"}
147
+
148
+ file_path = os.path.join(info["job_dir"], filename)
149
+ if not os.path.exists(file_path):
150
+ return {"error": "Output file not found"}
151
+
152
+ media_type = "application/pdf" if filename.endswith(".pdf") else \
153
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
154
+
155
+ return FileResponse(
156
+ path=file_path,
157
+ filename=filename,
158
+ media_type=media_type,
159
+ )
app/templates/index.html ADDED
@@ -0,0 +1,709 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>MD to Doc - Markdown Converter</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Google+Sans:wght@400;500;700&display=swap" rel="stylesheet">
9
+ <script src="https://cdn.tailwindcss.com"></script>
10
+ <script>
11
+ tailwind.config = {
12
+ theme: {
13
+ extend: {
14
+ fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] },
15
+ colors: {
16
+ m3: {
17
+ primary: '#1a73e8',
18
+ 'on-primary': '#ffffff',
19
+ 'primary-container': '#d3e3fd',
20
+ 'on-primary-container': '#041e49',
21
+ secondary: '#5f6368',
22
+ surface: '#f8fafb',
23
+ 'surface-container': '#ffffff',
24
+ 'surface-container-high': '#f1f3f4',
25
+ 'on-surface': '#1f1f1f',
26
+ 'on-surface-variant': '#5f6368',
27
+ outline: '#c4c7c5',
28
+ 'outline-variant': '#e1e3e1',
29
+ error: '#b3261e',
30
+ 'error-container': '#f9dedc',
31
+ success: '#0d652d',
32
+ 'success-container': '#c4eed0',
33
+ }
34
+ },
35
+ borderRadius: { 'xl': '16px', '2xl': '28px' },
36
+ boxShadow: {
37
+ 'm3-1': '0 1px 2px rgba(0,0,0,0.1), 0 1px 3px rgba(0,0,0,0.08)',
38
+ 'm3-2': '0 1px 3px rgba(0,0,0,0.12), 0 4px 8px rgba(0,0,0,0.08)',
39
+ 'm3-3': '0 4px 8px rgba(0,0,0,0.12), 0 8px 16px rgba(0,0,0,0.08)',
40
+ }
41
+ }
42
+ }
43
+ }
44
+ </script>
45
+ <style>
46
+ * { box-sizing: border-box; }
47
+ body { background: #f0f2f5; min-height: 100vh; }
48
+
49
+ /* M3 Animations */
50
+ @keyframes fadeInUp {
51
+ from { opacity: 0; transform: translateY(16px); }
52
+ to { opacity: 1; transform: translateY(0); }
53
+ }
54
+ @keyframes fadeIn {
55
+ from { opacity: 0; }
56
+ to { opacity: 1; }
57
+ }
58
+ @keyframes scaleIn {
59
+ from { opacity: 0; transform: scale(0.92); }
60
+ to { opacity: 1; transform: scale(1); }
61
+ }
62
+ @keyframes slideInRight {
63
+ from { opacity: 0; transform: translateX(20px); }
64
+ to { opacity: 1; transform: translateX(0); }
65
+ }
66
+ @keyframes shimmer {
67
+ 0% { background-position: -200% 0; }
68
+ 100% { background-position: 200% 0; }
69
+ }
70
+ @keyframes pulseRing {
71
+ 0% { box-shadow: 0 0 0 0 rgba(26, 115, 232, 0.3); }
72
+ 70% { box-shadow: 0 0 0 12px rgba(26, 115, 232, 0); }
73
+ 100% { box-shadow: 0 0 0 0 rgba(26, 115, 232, 0); }
74
+ }
75
+ @keyframes checkmark {
76
+ 0% { stroke-dashoffset: 24; }
77
+ 100% { stroke-dashoffset: 0; }
78
+ }
79
+ @keyframes progressPulse {
80
+ 0%, 100% { opacity: 1; }
81
+ 50% { opacity: 0.7; }
82
+ }
83
+
84
+ .animate-fade-in-up { animation: fadeInUp 0.4s cubic-bezier(0.2, 0, 0, 1) forwards; }
85
+ .animate-fade-in { animation: fadeIn 0.3s ease forwards; }
86
+ .animate-scale-in { animation: scaleIn 0.3s cubic-bezier(0.2, 0, 0, 1) forwards; }
87
+ .animate-slide-right { animation: slideInRight 0.35s cubic-bezier(0.2, 0, 0, 1) forwards; }
88
+
89
+ .skeleton {
90
+ background: linear-gradient(90deg, #e1e3e1 25%, #f1f3f4 50%, #e1e3e1 75%);
91
+ background-size: 200% 100%;
92
+ animation: shimmer 1.5s infinite;
93
+ }
94
+
95
+ /* Drop zone */
96
+ .drop-zone { transition: all 0.25s cubic-bezier(0.2, 0, 0, 1); }
97
+ .drop-zone.drag-over {
98
+ border-color: #1a73e8 !important;
99
+ background: #e8f0fe !important;
100
+ transform: scale(1.01);
101
+ }
102
+ .drop-zone.drag-over .drop-icon { transform: translateY(-4px); }
103
+ .drop-icon { transition: transform 0.3s cubic-bezier(0.2, 0, 0, 1); }
104
+
105
+ /* Progress bar */
106
+ .progress-bar {
107
+ transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
108
+ background: linear-gradient(90deg, #1a73e8, #4285f4, #1a73e8);
109
+ background-size: 200% 100%;
110
+ animation: shimmer 2s infinite;
111
+ }
112
+
113
+ /* Step items */
114
+ .step-item { transition: all 0.3s cubic-bezier(0.2, 0, 0, 1); }
115
+
116
+ /* Button ripple */
117
+ .btn-primary {
118
+ position: relative; overflow: hidden;
119
+ transition: all 0.2s cubic-bezier(0.2, 0, 0, 1);
120
+ }
121
+ .btn-primary:hover { box-shadow: 0 2px 8px rgba(26, 115, 232, 0.4); transform: translateY(-1px); }
122
+ .btn-primary:active { transform: translateY(0) scale(0.98); }
123
+ .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; }
124
+
125
+ /* File item hover */
126
+ .file-item { transition: all 0.2s ease; }
127
+ .file-item:hover { background: #f1f3f4; }
128
+
129
+ /* Download card */
130
+ .download-card {
131
+ transition: all 0.2s cubic-bezier(0.2, 0, 0, 1);
132
+ }
133
+ .download-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.12); }
134
+
135
+ /* Section transitions */
136
+ .section-enter {
137
+ max-height: 0; opacity: 0; overflow: hidden;
138
+ transition: max-height 0.5s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
139
+ }
140
+ .section-enter.visible {
141
+ max-height: 800px; opacity: 1;
142
+ }
143
+
144
+ /* Format toggle */
145
+ .format-btn {
146
+ transition: all 0.2s cubic-bezier(0.2, 0, 0, 1);
147
+ }
148
+ .format-btn.active {
149
+ background: #1a73e8; color: white; box-shadow: 0 2px 6px rgba(26, 115, 232, 0.3);
150
+ }
151
+
152
+ /* Custom checkbox */
153
+ .m3-checkbox {
154
+ appearance: none; width: 20px; height: 20px; border: 2px solid #5f6368;
155
+ border-radius: 4px; cursor: pointer; position: relative;
156
+ transition: all 0.15s ease;
157
+ }
158
+ .m3-checkbox:checked {
159
+ background: #1a73e8; border-color: #1a73e8;
160
+ }
161
+ .m3-checkbox:checked::after {
162
+ content: ''; position: absolute; left: 5px; top: 1px;
163
+ width: 6px; height: 11px; border: solid white; border-width: 0 2px 2px 0;
164
+ transform: rotate(45deg);
165
+ }
166
+
167
+ /* Toast */
168
+ .toast {
169
+ animation: fadeInUp 0.3s cubic-bezier(0.2, 0, 0, 1) forwards;
170
+ }
171
+ .toast.exit {
172
+ animation: fadeIn 0.2s ease reverse forwards;
173
+ }
174
+ </style>
175
+ </head>
176
+ <body class="font-sans text-m3-on-surface antialiased">
177
+
178
+ <!-- Header -->
179
+ <header class="bg-white/80 backdrop-blur-md border-b border-m3-outline-variant sticky top-0 z-50">
180
+ <div class="max-w-5xl mx-auto px-6 py-4 flex items-center gap-3">
181
+ <div class="w-10 h-10 rounded-xl bg-m3-primary flex items-center justify-center shadow-m3-1">
182
+ <svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
183
+ <path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
184
+ </svg>
185
+ </div>
186
+ <div>
187
+ <h1 class="text-lg font-semibold text-m3-on-surface leading-tight">MD to Doc</h1>
188
+ <p class="text-xs text-m3-on-surface-variant">Universal Markdown to PDF & DOCX converter</p>
189
+ </div>
190
+ </div>
191
+ </header>
192
+
193
+ <!-- Main Content -->
194
+ <main class="max-w-5xl mx-auto px-4 sm:px-6 py-8">
195
+
196
+ <!-- Upload Card -->
197
+ <div class="bg-white rounded-2xl shadow-m3-2 overflow-hidden animate-fade-in-up">
198
+ <div class="grid grid-cols-1 md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-m3-outline-variant">
199
+
200
+ <!-- Drop Zone (Left) -->
201
+ <div class="p-6 sm:p-8 flex items-center justify-center">
202
+ <div id="dropZone" class="drop-zone w-full border-2 border-dashed border-m3-outline rounded-2xl p-8 sm:p-10 text-center cursor-pointer hover:border-m3-primary/50 hover:bg-m3-primary/[0.02]"
203
+ onclick="document.getElementById('fileInput').click()">
204
+ <div class="drop-icon flex justify-center mb-4">
205
+ <div class="w-14 h-14 rounded-full bg-m3-primary-container flex items-center justify-center">
206
+ <svg class="w-7 h-7 text-m3-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
207
+ <path stroke-linecap="round" stroke-linejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
208
+ </svg>
209
+ </div>
210
+ </div>
211
+ <p class="text-base font-medium text-m3-on-surface mb-1">Drag & Drop files to upload</p>
212
+ <p class="text-sm text-m3-on-surface-variant mb-4">or</p>
213
+ <button type="button" class="inline-flex items-center gap-2 px-6 py-2.5 bg-m3-primary text-white text-sm font-medium rounded-full shadow-m3-1 hover:shadow-m3-2 transition-all duration-200 hover:-translate-y-0.5 active:scale-95">
214
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
215
+ <path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
216
+ </svg>
217
+ Browse
218
+ </button>
219
+ <p class="text-xs text-m3-on-surface-variant mt-4">Supported files: .md (Markdown)</p>
220
+ <input type="file" id="fileInput" accept=".md" class="hidden" multiple>
221
+ </div>
222
+ </div>
223
+
224
+ <!-- File List (Right) -->
225
+ <div class="p-6 sm:p-8 min-h-[300px] flex flex-col">
226
+ <h2 class="text-base font-semibold text-m3-on-surface mb-4">Uploaded files</h2>
227
+
228
+ <!-- Empty State -->
229
+ <div id="emptyState" class="flex-1 flex flex-col items-center justify-center text-center py-6">
230
+ <div class="w-16 h-16 rounded-full bg-m3-surface-container-high flex items-center justify-center mb-3">
231
+ <svg class="w-8 h-8 text-m3-outline" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
232
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/>
233
+ </svg>
234
+ </div>
235
+ <p class="text-sm text-m3-on-surface-variant">No files uploaded yet</p>
236
+ <p class="text-xs text-m3-outline mt-1">Drop a .md file to get started</p>
237
+ </div>
238
+
239
+ <!-- File List -->
240
+ <div id="fileList" class="space-y-2 flex-1 hidden"></div>
241
+ </div>
242
+ </div>
243
+ </div>
244
+
245
+ <!-- Options Card -->
246
+ <div id="optionsCard" class="section-enter mt-6">
247
+ <div class="bg-white rounded-2xl shadow-m3-1 p-6 sm:p-8 animate-fade-in-up">
248
+ <h2 class="text-base font-semibold text-m3-on-surface mb-5">Conversion Options</h2>
249
+
250
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
251
+ <!-- Format Selection -->
252
+ <div>
253
+ <label class="text-sm font-medium text-m3-on-surface-variant mb-2.5 block">Output Format</label>
254
+ <div class="flex gap-2">
255
+ <button type="button" class="format-btn px-5 py-2.5 rounded-full text-sm font-medium border border-m3-outline text-m3-on-surface-variant hover:bg-m3-surface-container-high" data-format="pdf">PDF</button>
256
+ <button type="button" class="format-btn px-5 py-2.5 rounded-full text-sm font-medium border border-m3-outline text-m3-on-surface-variant hover:bg-m3-surface-container-high" data-format="docx">DOCX</button>
257
+ <button type="button" class="format-btn active px-5 py-2.5 rounded-full text-sm font-medium border border-transparent" data-format="both">Both</button>
258
+ </div>
259
+ </div>
260
+
261
+ <!-- Organization -->
262
+ <div>
263
+ <label for="orgName" class="text-sm font-medium text-m3-on-surface-variant mb-2.5 block">Organization Name</label>
264
+ <input type="text" id="orgName" placeholder="Optional - for cover page"
265
+ class="w-full px-4 py-2.5 rounded-xl border border-m3-outline text-sm bg-m3-surface focus:outline-none focus:border-m3-primary focus:ring-2 focus:ring-m3-primary/20 transition-all duration-200 placeholder:text-m3-outline">
266
+ </div>
267
+ </div>
268
+
269
+ <!-- Confidential Toggle -->
270
+ <div class="flex items-center gap-3 mt-5">
271
+ <input type="checkbox" id="confidential" checked class="m3-checkbox">
272
+ <label for="confidential" class="text-sm text-m3-on-surface cursor-pointer select-none">Mark as Confidential</label>
273
+ </div>
274
+
275
+ <!-- Convert Button -->
276
+ <div class="mt-6 flex justify-center">
277
+ <button id="convertBtn" onclick="startConversion()" class="btn-primary inline-flex items-center gap-2.5 px-10 py-3 bg-m3-primary text-white text-sm font-semibold rounded-full shadow-m3-1">
278
+ <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
279
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 00-3.7-3.7 48.678 48.678 0 00-7.324 0 4.006 4.006 0 00-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3l-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 003.7 3.7 48.656 48.656 0 007.324 0 4.006 4.006 0 003.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3l-3 3"/>
280
+ </svg>
281
+ Convert
282
+ </button>
283
+ </div>
284
+ </div>
285
+ </div>
286
+
287
+ <!-- Progress Card -->
288
+ <div id="progressCard" class="section-enter mt-6">
289
+ <div class="bg-white rounded-2xl shadow-m3-2 p-6 sm:p-8">
290
+
291
+ <!-- Progress Header -->
292
+ <div class="flex items-center justify-between mb-4">
293
+ <h2 class="text-base font-semibold text-m3-on-surface">Converting...</h2>
294
+ <span id="progressPercent" class="text-sm font-semibold text-m3-primary">0%</span>
295
+ </div>
296
+
297
+ <!-- Progress Bar -->
298
+ <div class="w-full h-2 bg-m3-surface-container-high rounded-full overflow-hidden mb-6">
299
+ <div id="progressBar" class="progress-bar h-full rounded-full" style="width: 0%"></div>
300
+ </div>
301
+
302
+ <!-- Steps -->
303
+ <div id="stepsList" class="space-y-2 max-h-[280px] overflow-y-auto pr-2"></div>
304
+ </div>
305
+ </div>
306
+
307
+ <!-- Results Card -->
308
+ <div id="resultsCard" class="section-enter mt-6">
309
+ <div class="bg-white rounded-2xl shadow-m3-2 p-6 sm:p-8">
310
+ <div class="flex items-center gap-3 mb-6">
311
+ <div class="w-10 h-10 rounded-full bg-m3-success-container flex items-center justify-center">
312
+ <svg class="w-5 h-5 text-m3-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
313
+ <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
314
+ </svg>
315
+ </div>
316
+ <div>
317
+ <h2 class="text-base font-semibold text-m3-on-surface">Conversion Complete</h2>
318
+ <p class="text-sm text-m3-on-surface-variant">Your files are ready to download</p>
319
+ </div>
320
+ </div>
321
+
322
+ <div id="downloadList" class="grid grid-cols-1 sm:grid-cols-2 gap-3"></div>
323
+
324
+ <!-- Convert Another -->
325
+ <div class="mt-6 pt-5 border-t border-m3-outline-variant flex justify-center">
326
+ <button onclick="resetAll()" class="inline-flex items-center gap-2 px-6 py-2.5 text-m3-primary text-sm font-medium rounded-full border border-m3-primary/30 hover:bg-m3-primary/5 transition-all duration-200">
327
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
328
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
329
+ </svg>
330
+ Convert Another File
331
+ </button>
332
+ </div>
333
+ </div>
334
+ </div>
335
+
336
+ <!-- Error Card -->
337
+ <div id="errorCard" class="section-enter mt-6">
338
+ <div class="bg-white rounded-2xl shadow-m3-2 p-6 sm:p-8 border-l-4 border-m3-error">
339
+ <div class="flex items-start gap-3">
340
+ <div class="w-10 h-10 rounded-full bg-m3-error-container flex items-center justify-center flex-shrink-0">
341
+ <svg class="w-5 h-5 text-m3-error" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
342
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"/>
343
+ </svg>
344
+ </div>
345
+ <div>
346
+ <h2 class="text-base font-semibold text-m3-error">Conversion Failed</h2>
347
+ <p id="errorMessage" class="text-sm text-m3-on-surface-variant mt-1"></p>
348
+ <button onclick="resetToUpload()" class="mt-4 inline-flex items-center gap-2 px-5 py-2 text-m3-error text-sm font-medium rounded-full border border-m3-error/30 hover:bg-m3-error/5 transition-all duration-200">
349
+ Try Again
350
+ </button>
351
+ </div>
352
+ </div>
353
+ </div>
354
+ </div>
355
+
356
+ </main>
357
+
358
+ <!-- Footer -->
359
+ <footer class="text-center py-6 text-xs text-m3-on-surface-variant">
360
+ MD to Doc v1.0 &mdash; Universal Markdown Converter
361
+ </footer>
362
+
363
+ <script>
364
+ // ─── State ───
365
+ let files = {};
366
+ let selectedFormat = 'both';
367
+ let currentJobId = null;
368
+
369
+ // ─── Elements ───
370
+ const dropZone = document.getElementById('dropZone');
371
+ const fileInput = document.getElementById('fileInput');
372
+ const fileList = document.getElementById('fileList');
373
+ const emptyState = document.getElementById('emptyState');
374
+ const optionsCard = document.getElementById('optionsCard');
375
+ const progressCard = document.getElementById('progressCard');
376
+ const resultsCard = document.getElementById('resultsCard');
377
+ const errorCard = document.getElementById('errorCard');
378
+ const progressBar = document.getElementById('progressBar');
379
+ const progressPercent = document.getElementById('progressPercent');
380
+ const stepsList = document.getElementById('stepsList');
381
+ const downloadList = document.getElementById('downloadList');
382
+ const convertBtn = document.getElementById('convertBtn');
383
+
384
+ // ─── Drag & Drop ───
385
+ ['dragenter', 'dragover'].forEach(ev => {
386
+ dropZone.addEventListener(ev, e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
387
+ });
388
+ ['dragleave', 'drop'].forEach(ev => {
389
+ dropZone.addEventListener(ev, e => { e.preventDefault(); dropZone.classList.remove('drag-over'); });
390
+ });
391
+ dropZone.addEventListener('drop', e => {
392
+ const dt = e.dataTransfer;
393
+ if (dt.files.length) handleFiles(dt.files);
394
+ });
395
+ fileInput.addEventListener('change', e => {
396
+ if (e.target.files.length) handleFiles(e.target.files);
397
+ e.target.value = '';
398
+ });
399
+
400
+ // ─── File Upload ───
401
+ async function handleFiles(filesList) {
402
+ for (const file of filesList) {
403
+ if (!file.name.endsWith('.md')) {
404
+ showToast('Only .md files are supported', 'error');
405
+ continue;
406
+ }
407
+ await uploadFile(file);
408
+ }
409
+ }
410
+
411
+ async function uploadFile(file) {
412
+ const formData = new FormData();
413
+ formData.append('file', file);
414
+
415
+ // Show uploading state
416
+ const tempId = 'temp-' + Date.now();
417
+ addFileToList(tempId, file.name, 'Uploading...', true);
418
+
419
+ try {
420
+ const resp = await fetch('/api/upload', { method: 'POST', body: formData });
421
+ const data = await resp.json();
422
+
423
+ if (data.error) {
424
+ removeFileFromList(tempId);
425
+ showToast(data.error, 'error');
426
+ return;
427
+ }
428
+
429
+ removeFileFromList(tempId);
430
+ files[data.id] = { filename: data.filename, size: data.size };
431
+ addFileToList(data.id, data.filename, data.size, false);
432
+ updateUI();
433
+ } catch (err) {
434
+ removeFileFromList(tempId);
435
+ showToast('Upload failed: ' + err.message, 'error');
436
+ }
437
+ }
438
+
439
+ function addFileToList(id, name, size, uploading) {
440
+ emptyState.classList.add('hidden');
441
+ fileList.classList.remove('hidden');
442
+
443
+ const ext = name.split('.').pop().toLowerCase();
444
+ const colors = { md: { bg: 'bg-blue-50', text: 'text-blue-600', label: 'MD' } };
445
+ const c = colors[ext] || { bg: 'bg-gray-50', text: 'text-gray-600', label: ext.toUpperCase() };
446
+
447
+ const el = document.createElement('div');
448
+ el.id = 'file-' + id;
449
+ el.className = 'file-item flex items-center gap-3 p-3 rounded-xl animate-slide-right';
450
+ el.innerHTML = `
451
+ <div class="w-10 h-10 ${c.bg} rounded-xl flex items-center justify-center flex-shrink-0">
452
+ <span class="text-xs font-bold ${c.text}">${c.label}</span>
453
+ </div>
454
+ <div class="flex-1 min-w-0">
455
+ <p class="text-sm font-medium text-m3-on-surface truncate">${name}</p>
456
+ ${uploading
457
+ ? `<div class="mt-1.5 h-1 bg-m3-surface-container-high rounded-full overflow-hidden">
458
+ <div class="h-full bg-m3-primary rounded-full" style="width:60%;animation:shimmer 1s infinite;background:linear-gradient(90deg,#1a73e8 25%,#4285f4 50%,#1a73e8 75%);background-size:200% 100%"></div>
459
+ </div>`
460
+ : `<p class="text-xs text-m3-on-surface-variant">${size}</p>`
461
+ }
462
+ </div>
463
+ ${!uploading ? `
464
+ <button onclick="deleteFile('${id}')" class="w-8 h-8 rounded-full flex items-center justify-center text-m3-error/60 hover:text-m3-error hover:bg-m3-error-container/50 transition-all duration-200 flex-shrink-0" title="Remove">
465
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
466
+ <path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/>
467
+ </svg>
468
+ </button>` : `
469
+ <div class="w-8 h-8 flex items-center justify-center flex-shrink-0">
470
+ <svg class="w-4 h-4 text-m3-outline animate-spin" fill="none" viewBox="0 0 24 24">
471
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
472
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
473
+ </svg>
474
+ </div>`}
475
+ `;
476
+ fileList.appendChild(el);
477
+ }
478
+
479
+ function removeFileFromList(id) {
480
+ const el = document.getElementById('file-' + id);
481
+ if (el) {
482
+ el.style.opacity = '0';
483
+ el.style.transform = 'translateX(20px)';
484
+ el.style.transition = 'all 0.25s ease';
485
+ setTimeout(() => el.remove(), 250);
486
+ }
487
+ }
488
+
489
+ async function deleteFile(id) {
490
+ await fetch(`/api/files/${id}`, { method: 'DELETE' });
491
+ delete files[id];
492
+ removeFileFromList(id);
493
+ setTimeout(updateUI, 300);
494
+ }
495
+
496
+ // ─── UI State ───
497
+ function updateUI() {
498
+ const hasFiles = Object.keys(files).length > 0;
499
+ if (!hasFiles) {
500
+ emptyState.classList.remove('hidden');
501
+ fileList.classList.add('hidden');
502
+ optionsCard.classList.remove('visible');
503
+ } else {
504
+ optionsCard.classList.add('visible');
505
+ }
506
+ }
507
+
508
+ // ─── Format Toggle ───
509
+ document.querySelectorAll('.format-btn').forEach(btn => {
510
+ btn.addEventListener('click', () => {
511
+ document.querySelectorAll('.format-btn').forEach(b => {
512
+ b.classList.remove('active');
513
+ b.classList.add('border-m3-outline', 'text-m3-on-surface-variant');
514
+ b.classList.remove('border-transparent');
515
+ });
516
+ btn.classList.add('active');
517
+ btn.classList.remove('border-m3-outline', 'text-m3-on-surface-variant');
518
+ btn.classList.add('border-transparent');
519
+ selectedFormat = btn.dataset.format;
520
+ });
521
+ });
522
+
523
+ // ─── Conversion ───
524
+ async function startConversion() {
525
+ const fileIds = Object.keys(files);
526
+ if (fileIds.length === 0) {
527
+ showToast('Please upload a markdown file first', 'error');
528
+ return;
529
+ }
530
+
531
+ const fileId = fileIds[0]; // Convert first file
532
+ const orgName = document.getElementById('orgName').value;
533
+ const confidential = document.getElementById('confidential').checked;
534
+
535
+ // Show progress
536
+ convertBtn.disabled = true;
537
+ progressCard.classList.add('visible');
538
+ resultsCard.classList.remove('visible');
539
+ errorCard.classList.remove('visible');
540
+ stepsList.innerHTML = '';
541
+ progressBar.style.width = '0%';
542
+ progressPercent.textContent = '0%';
543
+
544
+ try {
545
+ const formData = new FormData();
546
+ formData.append('file_id', fileId);
547
+ formData.append('format', selectedFormat);
548
+ formData.append('org_name', orgName);
549
+ formData.append('confidential', confidential);
550
+
551
+ const resp = await fetch('/api/convert', { method: 'POST', body: formData });
552
+ const data = await resp.json();
553
+
554
+ if (data.error) {
555
+ showError(data.error);
556
+ return;
557
+ }
558
+
559
+ currentJobId = data.job_id;
560
+ listenProgress(data.job_id);
561
+ } catch (err) {
562
+ showError(err.message);
563
+ }
564
+ }
565
+
566
+ function listenProgress(jobId) {
567
+ const eventSource = new EventSource(`/api/progress/${jobId}`);
568
+ let stepIndex = 0;
569
+
570
+ eventSource.onmessage = (event) => {
571
+ const data = JSON.parse(event.data);
572
+
573
+ if (data.type === 'progress') {
574
+ progressBar.style.width = data.percent + '%';
575
+ progressPercent.textContent = data.percent + '%';
576
+ addStep(data.step, data.detail, stepIndex++);
577
+ } else if (data.type === 'complete') {
578
+ eventSource.close();
579
+ progressBar.style.width = '100%';
580
+ progressPercent.textContent = '100%';
581
+ addStep('Conversion complete!', '', stepIndex++, true);
582
+
583
+ setTimeout(() => {
584
+ showResults(data.results, jobId);
585
+ convertBtn.disabled = false;
586
+ }, 600);
587
+ } else if (data.type === 'error') {
588
+ eventSource.close();
589
+ showError(data.message);
590
+ convertBtn.disabled = false;
591
+ }
592
+ };
593
+
594
+ eventSource.onerror = () => {
595
+ eventSource.close();
596
+ showError('Connection to server lost. Please try again.');
597
+ convertBtn.disabled = false;
598
+ };
599
+ }
600
+
601
+ function addStep(text, detail, index, isComplete = false) {
602
+ const el = document.createElement('div');
603
+ el.className = 'step-item flex items-start gap-3 animate-fade-in';
604
+ el.style.animationDelay = '0.05s';
605
+
606
+ const iconClass = isComplete
607
+ ? 'bg-m3-success-container text-m3-success'
608
+ : 'bg-m3-primary-container text-m3-primary';
609
+ const icon = isComplete
610
+ ? '<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>'
611
+ : '<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>';
612
+
613
+ el.innerHTML = `
614
+ <div class="w-6 h-6 rounded-full ${iconClass} flex items-center justify-center flex-shrink-0 mt-0.5">
615
+ <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
616
+ ${icon}
617
+ </svg>
618
+ </div>
619
+ <div class="flex-1 min-w-0">
620
+ <p class="text-sm font-medium text-m3-on-surface">${text}</p>
621
+ ${detail ? `<p class="text-xs text-m3-on-surface-variant mt-0.5">${detail}</p>` : ''}
622
+ </div>
623
+ `;
624
+ stepsList.appendChild(el);
625
+ stepsList.scrollTop = stepsList.scrollHeight;
626
+ }
627
+
628
+ function showResults(results, jobId) {
629
+ resultsCard.classList.add('visible');
630
+ downloadList.innerHTML = '';
631
+
632
+ results.forEach((file, i) => {
633
+ const isPdf = file.type === 'pdf';
634
+ const bgColor = isPdf ? 'bg-red-50' : 'bg-blue-50';
635
+ const textColor = isPdf ? 'text-red-600' : 'text-blue-600';
636
+ const borderColor = isPdf ? 'border-red-100' : 'border-blue-100';
637
+ const label = isPdf ? 'PDF' : 'DOCX';
638
+ const icon = isPdf
639
+ ? '<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/>'
640
+ : '<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/>';
641
+
642
+ const card = document.createElement('a');
643
+ card.href = `/api/download/${jobId}/${file.name}`;
644
+ card.className = `download-card flex items-center gap-4 p-4 rounded-xl border ${borderColor} ${bgColor}/30 cursor-pointer animate-scale-in`;
645
+ card.style.animationDelay = `${i * 0.1}s`;
646
+ card.innerHTML = `
647
+ <div class="w-12 h-12 ${bgColor} rounded-xl flex items-center justify-center flex-shrink-0">
648
+ <span class="text-xs font-bold ${textColor}">${label}</span>
649
+ </div>
650
+ <div class="flex-1 min-w-0">
651
+ <p class="text-sm font-medium text-m3-on-surface truncate">${file.name}</p>
652
+ <p class="text-xs text-m3-on-surface-variant">${file.size}</p>
653
+ </div>
654
+ <div class="w-10 h-10 rounded-full bg-white shadow-m3-1 flex items-center justify-center flex-shrink-0">
655
+ <svg class="w-5 h-5 ${textColor}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
656
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/>
657
+ </svg>
658
+ </div>
659
+ `;
660
+ downloadList.appendChild(card);
661
+ });
662
+ }
663
+
664
+ function showError(message) {
665
+ progressCard.classList.remove('visible');
666
+ errorCard.classList.add('visible');
667
+ document.getElementById('errorMessage').textContent = message;
668
+ convertBtn.disabled = false;
669
+ }
670
+
671
+ // ─── Reset ───
672
+ function resetAll() {
673
+ // Clean up server files
674
+ Object.keys(files).forEach(id => {
675
+ fetch(`/api/files/${id}`, { method: 'DELETE' });
676
+ });
677
+ files = {};
678
+ fileList.innerHTML = '';
679
+ emptyState.classList.remove('hidden');
680
+ fileList.classList.add('hidden');
681
+ optionsCard.classList.remove('visible');
682
+ progressCard.classList.remove('visible');
683
+ resultsCard.classList.remove('visible');
684
+ errorCard.classList.remove('visible');
685
+ convertBtn.disabled = false;
686
+ }
687
+
688
+ function resetToUpload() {
689
+ progressCard.classList.remove('visible');
690
+ resultsCard.classList.remove('visible');
691
+ errorCard.classList.remove('visible');
692
+ convertBtn.disabled = false;
693
+ }
694
+
695
+ // ─── Toast ───
696
+ function showToast(message, type = 'info') {
697
+ const toast = document.createElement('div');
698
+ const bgColor = type === 'error' ? 'bg-m3-error' : 'bg-m3-primary';
699
+ toast.className = `toast fixed bottom-6 left-1/2 -translate-x-1/2 ${bgColor} text-white px-6 py-3 rounded-full shadow-m3-3 text-sm font-medium z-50`;
700
+ toast.textContent = message;
701
+ document.body.appendChild(toast);
702
+ setTimeout(() => {
703
+ toast.classList.add('exit');
704
+ setTimeout(() => toast.remove(), 200);
705
+ }, 3000);
706
+ }
707
+ </script>
708
+ </body>
709
+ </html>
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.6
2
+ uvicorn[standard]==0.34.0
3
+ jinja2==3.1.5
4
+ python-multipart==0.0.20
5
+ markdown==3.7
6
+ weasyprint==63.1
7
+ beautifulsoup4==4.13.3
8
+ Pillow==11.1.0
9
+ python-docx==1.1.2