diff --git a/viet-font-app/README.md b/viet-font-app/README.md new file mode 100644 index 0000000000..5aa8e6e311 --- /dev/null +++ b/viet-font-app/README.md @@ -0,0 +1,93 @@ +# Vietnamese Font Generator + +AI-powered tool for adding Vietnamese diacritical marks to existing fonts, following [Google Fonts diacritics guidelines](https://googlefonts.github.io/gf-guide/diacritics.html). + +## Features + +- **AI Font Analysis**: Upload sample images and let AI (Gemini or ChatGPT) analyze the font style +- **Automatic Glyph Generation**: AI generates Vietnamese diacritical marks matching the font's style +- **Composite Glyphs**: When possible, creates composite glyphs from existing base characters and marks +- **Google Fonts Compliant**: Follows proper anchor-based mark positioning guidelines +- **TTF Export**: Downloads the Vietnamized font as a standard TrueType font file + +## Vietnamese Character Support + +The tool generates all Vietnamese-specific characters including: +- **Base characters**: Ă, Â, Đ, Ê, Ô, Ơ, Ư (and lowercase) +- **Tone marks**: Acute (sắc), Grave (huyền), Hook above (hỏi), Tilde (ngã), Dot below (nặng) +- **Combined characters**: All combinations of base + tone marks (134+ characters total) + +## Quick Start + +### Requirements + +- Python 3.9+ +- A Gemini API key or OpenAI API key + +### Installation + +```bash +cd viet-font-app +pip install -r requirements.txt +``` + +### Running + +```bash +uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload +``` + +Then open http://localhost:8000 in your browser. + +### Usage + +1. **Select AI Provider**: Choose between Google Gemini or OpenAI ChatGPT +2. **Enter API Key**: Provide your API key for the selected provider +3. **Upload Font**: Upload your base font file (.ttf, .otf, .woff, .woff2) +4. **Upload Sample Images** (optional): Add images showing the font style for better AI analysis +5. **Generate**: Click "Generate Vietnamese Font" and wait for processing +6. **Download**: Download the Vietnamized font as a .ttf file + +## How It Works + +1. **Font Analysis**: The tool reads the uploaded font and identifies which Vietnamese characters are missing +2. **Style Analysis**: If sample images are provided, AI analyzes the font's visual characteristics (weight, contrast, terminals, construction) +3. **Composite Generation**: For characters that can be built from existing glyphs (base + combining marks), composite glyphs are created +4. **AI Glyph Generation**: For missing characters, AI generates SVG path data matching the analyzed style +5. **Font Assembly**: Generated glyphs are added to the font using fonttools, following Google Fonts diacritics guidelines +6. **Export**: The modified font is saved as a TrueType (.ttf) file + +## Technical Details + +### Diacritics Guidelines (Google Fonts) + +Following the [Google Fonts diacritics guide](https://googlefonts.github.io/gf-guide/diacritics.html): + +- **Anchor-based positioning**: Uses `top`, `bottom`, and `top_viet` anchors +- **Combining marks**: Zero-width marks with proper Unicode codepoints +- **Stacked diacritics**: Vietnamese-specific stacked mark handling +- **GDEF classification**: Proper mark glyph classification (class 3) +- **Case variants**: Separate `.case` marks for capital letters + +### Tech Stack + +- **Backend**: FastAPI + fonttools + Pillow +- **Frontend**: Vanilla HTML/CSS/JS +- **AI**: Google Gemini 2.0 Flash / OpenAI GPT-4o +- **Font Processing**: fonttools library + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/` | Frontend UI | +| POST | `/api/analyze-font` | Analyze font for missing Vietnamese chars | +| POST | `/api/generate-font` | Generate Vietnamese font (direct download) | +| POST | `/api/generate-font-stream` | Generate font with detailed results JSON | +| GET | `/api/download/{session_id}` | Download previously generated font | +| DELETE | `/api/cleanup/{session_id}` | Clean up temporary files | +| GET | `/api/health` | Health check | + +## License + +This tool is part of the FontForge project. See the main [LICENSE](../LICENSE) file. diff --git a/viet-font-app/backend/__init__.py b/viet-font-app/backend/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/viet-font-app/backend/ai_client.py b/viet-font-app/backend/ai_client.py new file mode 100644 index 0000000000..62ae225bd6 --- /dev/null +++ b/viet-font-app/backend/ai_client.py @@ -0,0 +1,214 @@ +"""AI client module for font style analysis and glyph generation. + +Supports both Google Gemini and OpenAI (ChatGPT) APIs. +Uses vision capabilities to analyze font styles from images +and generate SVG path data for Vietnamese diacritical marks. +""" + +import base64 +import json +import logging +import re +from pathlib import Path + +logger = logging.getLogger(__name__) + +FONT_ANALYSIS_PROMPT = """You are a professional font designer and typographer. +Analyze this font sample image and describe the font's visual characteristics in detail: +- Stroke weight and contrast (thin/regular/bold, monolinear/high contrast) +- Serif style (sans-serif, serif, slab-serif, etc.) +- Letter construction (geometric, humanist, transitional, etc.) +- Stroke terminals (round, square, teardrop, etc.) +- Overall proportions and x-height +- Any distinctive stylistic features + +Provide your analysis as a structured JSON object with these fields: +{ + "weight": "thin|light|regular|medium|bold|black", + "contrast": "monolinear|low|medium|high", + "serif_style": "sans-serif|serif|slab-serif|decorative", + "construction": "geometric|humanist|transitional|modern|decorative", + "terminals": "round|square|teardrop|ball|flat", + "x_height": "small|medium|large", + "style_notes": "any additional distinctive features" +} + +Return ONLY the JSON object, no other text.""" + +GLYPH_GENERATION_PROMPT = """You are a professional font designer. +Based on this font style analysis: {style_analysis} + +Generate SVG path data for the Vietnamese diacritical mark: "{mark_name}" +This mark will be used as a combining diacritical mark in the font. + +The mark should: +- Match the style described in the analysis (weight, contrast, terminals) +- Follow Google Fonts diacritics guidelines +- Be designed at a UPM (units per em) of {upm} +- Have appropriate proportions for the font's x-height + +For reference, the font's ascender is {ascender} and descender is {descender}. +The base glyph width is approximately {avg_width}. + +Return ONLY a JSON object with this structure: +{{ + "svg_path": "M ... Z", + "width": , + "height": , + "offset_x": , + "offset_y": , + "description": "brief description of the mark design" +}} + +The svg_path should be a valid SVG path data string using absolute coordinates. +Width and height are the bounding box of the mark. +offset_x and offset_y are the recommended positioning offsets from the base glyph's anchor point. + +Return ONLY the JSON object, no other text.""" + +FULL_CHAR_GENERATION_PROMPT = """You are a professional font designer. +Based on this font style analysis: {style_analysis} + +Generate an SVG path for the Vietnamese character "{char}" (U+{codepoint:04X}). +This character is composed of base "{base}" with the following marks: {marks}. + +The design should: +- Match the style described (weight, contrast, terminals, construction) +- Follow Google Fonts Vietnamese diacritics guidelines +- Use proper mark positioning with appropriate spacing +- Be designed at UPM of {upm} +- Diacritics should be coherent and harmonious with the base letter +- Marks should be at consistent distance from base letter +- Symmetric marks centered on optical center of base + +Font metrics: ascender={ascender}, descender={descender}, avg_width={avg_width} + +Return ONLY a JSON object: +{{ + "svg_path": "M ... Z", + "width": , + "lsb": , + "components": [ + {{"name": "base_or_mark_name", "svg_path": "M...Z", "offset_x": 0, "offset_y": 0}} + ], + "description": "brief description" +}} + +Return ONLY the JSON object.""" + + +def _extract_json(text: str) -> dict: + """Extract JSON from AI response text, handling markdown code blocks.""" + text = text.strip() + json_match = re.search(r'```(?:json)?\s*([\s\S]*?)```', text) + if json_match: + text = json_match.group(1).strip() + try: + return json.loads(text) + except json.JSONDecodeError: + json_match = re.search(r'\{[\s\S]*\}', text) + if json_match: + return json.loads(json_match.group(0)) + raise ValueError(f"Could not parse JSON from AI response: {text[:200]}") + + +def _encode_image(image_path: str) -> str: + """Encode an image file to base64.""" + with open(image_path, "rb") as f: + return base64.b64encode(f.read()).decode("utf-8") + + +def _get_image_mime(image_path: str) -> str: + """Get MIME type from file extension.""" + ext = Path(image_path).suffix.lower() + mime_map = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", + } + return mime_map.get(ext, "image/png") + + +class GeminiClient: + """Client for Google Gemini API.""" + + def __init__(self, api_key: str): + import google.generativeai as genai + genai.configure(api_key=api_key) + self.model = genai.GenerativeModel("gemini-2.5-pro-preview-05-06") + + def analyze_font_style(self, image_paths: list[str]) -> dict: + """Analyze font style from sample images.""" + import google.generativeai as genai + parts = [FONT_ANALYSIS_PROMPT] + for path in image_paths: + img_data = _encode_image(path) + mime = _get_image_mime(path) + parts.append({ + "inline_data": { + "mime_type": mime, + "data": img_data, + } + }) + response = self.model.generate_content(parts) + return _extract_json(response.text) + + def generate_glyph(self, prompt: str) -> dict: + """Generate glyph data from a text prompt.""" + response = self.model.generate_content(prompt) + return _extract_json(response.text) + + +class OpenAIClient: + """Client for OpenAI ChatGPT API.""" + + def __init__(self, api_key: str): + from openai import OpenAI + self.client = OpenAI(api_key=api_key) + self.model = "gpt-4o" + + def analyze_font_style(self, image_paths: list[str]) -> dict: + """Analyze font style from sample images.""" + messages = [{"role": "system", "content": "You are a professional font designer."}] + content = [{"type": "text", "text": FONT_ANALYSIS_PROMPT}] + for path in image_paths: + img_data = _encode_image(path) + mime = _get_image_mime(path) + content.append({ + "type": "image_url", + "image_url": { + "url": f"data:{mime};base64,{img_data}", + }, + }) + messages.append({"role": "user", "content": content}) + response = self.client.chat.completions.create( + model=self.model, + messages=messages, + max_tokens=2000, + ) + return _extract_json(response.choices[0].message.content) + + def generate_glyph(self, prompt: str) -> dict: + """Generate glyph data from a text prompt.""" + response = self.client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": "You are a professional font designer."}, + {"role": "user", "content": prompt}, + ], + max_tokens=4000, + ) + return _extract_json(response.choices[0].message.content) + + +def create_ai_client(provider: str, api_key: str): + """Factory function to create the appropriate AI client.""" + if provider == "gemini": + return GeminiClient(api_key) + elif provider == "openai": + return OpenAIClient(api_key) + else: + raise ValueError(f"Unsupported AI provider: {provider}. Use 'gemini' or 'openai'.") diff --git a/viet-font-app/backend/font_processor.py b/viet-font-app/backend/font_processor.py new file mode 100644 index 0000000000..f801f92053 --- /dev/null +++ b/viet-font-app/backend/font_processor.py @@ -0,0 +1,330 @@ +"""Font processing module using fonttools. + +Handles loading fonts, analyzing metrics, adding Vietnamese glyphs, +and exporting the modified font as TTF. +""" + +import io +import logging +import re +from pathlib import Path + +from fontTools.pens.ttGlyphPen import TTGlyphPointPen +from fontTools.ttLib import TTFont + +from .vietnamese import ( + COMBINING_MARKS, + MARK_GLYPH_NAMES, + VIET_BASE_CHARS, + VIET_PRECOMPOSED, + get_missing_vietnamese_chars, +) + +logger = logging.getLogger(__name__) + + +def load_font(font_path: str) -> TTFont: + """Load a font file (TTF or OTF).""" + return TTFont(font_path) + + +def get_font_metrics(font: TTFont) -> dict: + """Extract key metrics from the font.""" + head = font["head"] + os2 = font["OS/2"] + cmap = font.getBestCmap() or {} + + glyph_widths = [] + if "hmtx" in font: + hmtx = font["hmtx"] + for glyph_name in font.getGlyphOrder(): + if glyph_name in hmtx.metrics: + width, _lsb = hmtx.metrics[glyph_name] + if width > 0: + glyph_widths.append(width) + + avg_width = int(sum(glyph_widths) / len(glyph_widths)) if glyph_widths else head.unitsPerEm // 2 + + return { + "upm": head.unitsPerEm, + "ascender": os2.sTypoAscender, + "descender": os2.sTypoDescender, + "x_height": os2.sxHeight if hasattr(os2, "sxHeight") and os2.sxHeight else int(head.unitsPerEm * 0.5), + "cap_height": os2.sCapHeight if hasattr(os2, "sCapHeight") and os2.sCapHeight else int(head.unitsPerEm * 0.7), + "avg_width": avg_width, + "num_glyphs": len(font.getGlyphOrder()), + "has_glyf": "glyf" in font, + "has_cff": "CFF " in font, + } + + +def get_font_cmap(font: TTFont) -> dict: + """Get the font's character map (unicode -> glyph name).""" + return font.getBestCmap() or {} + + +def analyze_missing_chars(font: TTFont) -> dict: + """Analyze which Vietnamese characters are missing from the font.""" + cmap = get_font_cmap(font) + missing = get_missing_vietnamese_chars(cmap) + + present_base = {} + for char, cp in VIET_BASE_CHARS.items(): + present_base[char] = cp in cmap + + present_marks = {} + for mark_name, cp in COMBINING_MARKS.items(): + present_marks[mark_name] = cp in cmap + + return { + "total_missing": len(missing), + "missing_chars": missing, + "base_chars_present": present_base, + "combining_marks_present": present_marks, + } + + +def _parse_svg_path(svg_path: str) -> list: + """Parse SVG path data into a list of drawing commands. + + Returns a list of tuples: (command, [coords]) + """ + commands = [] + tokens = re.findall(r'[MmLlHhVvCcSsQqTtAaZz]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?', svg_path) + + current_cmd = None + current_coords = [] + + for token in tokens: + if token.isalpha(): + if current_cmd is not None: + commands.append((current_cmd, current_coords)) + current_cmd = token + current_coords = [] + else: + current_coords.append(float(token)) + + if current_cmd is not None: + commands.append((current_cmd, current_coords)) + + return commands + + +def _svg_path_to_glyph_contours(svg_path: str, upm: int) -> list: + """Convert SVG path data to glyph contour points. + + Returns list of contours, where each contour is a list of (x, y, on_curve) tuples. + """ + commands = _parse_svg_path(svg_path) + contours = [] + current_contour = [] + cx, cy = 0.0, 0.0 + + for cmd, coords in commands: + if cmd == "M": + if current_contour: + contours.append(current_contour) + current_contour = [] + cx, cy = coords[0], coords[1] + current_contour.append((int(cx), int(upm - cy), True)) + i = 2 + while i + 1 < len(coords): + cx, cy = coords[i], coords[i + 1] + current_contour.append((int(cx), int(upm - cy), True)) + i += 2 + + elif cmd == "L": + i = 0 + while i + 1 < len(coords): + cx, cy = coords[i], coords[i + 1] + current_contour.append((int(cx), int(upm - cy), True)) + i += 2 + + elif cmd == "H": + for val in coords: + cx = val + current_contour.append((int(cx), int(upm - cy), True)) + + elif cmd == "V": + for val in coords: + cy = val + current_contour.append((int(cx), int(upm - cy), True)) + + elif cmd == "C": + i = 0 + while i + 5 < len(coords): + x1, y1 = coords[i], coords[i + 1] + x2, y2 = coords[i + 2], coords[i + 3] + x3, y3 = coords[i + 4], coords[i + 5] + current_contour.append((int(x1), int(upm - y1), False)) + current_contour.append((int(x2), int(upm - y2), False)) + current_contour.append((int(x3), int(upm - y3), True)) + cx, cy = x3, y3 + i += 6 + + elif cmd == "Q": + i = 0 + while i + 3 < len(coords): + x1, y1 = coords[i], coords[i + 1] + x2, y2 = coords[i + 2], coords[i + 3] + current_contour.append((int(x1), int(upm - y1), False)) + current_contour.append((int(x2), int(upm - y2), True)) + cx, cy = x2, y2 + i += 4 + + elif cmd in ("Z", "z"): + if current_contour: + contours.append(current_contour) + current_contour = [] + + if current_contour: + contours.append(current_contour) + + return contours + + +def add_glyph_from_svg(font: TTFont, glyph_name: str, codepoint: int, + svg_path: str, advance_width: int) -> bool: + """Add a glyph to the font from SVG path data. + + Works with TrueType (glyf) fonts. + """ + if not font.get("glyf"): + logger.warning("Font does not have 'glyf' table; cannot add TrueType glyphs") + return False + + metrics = get_font_metrics(font) + upm = metrics["upm"] + + contours = _svg_path_to_glyph_contours(svg_path, upm) + if not contours: + logger.warning(f"No contours generated for glyph {glyph_name}") + return False + + pen = TTGlyphPointPen(None) + + for contour in contours: + pen.beginPath() + for x, y, on_curve in contour: + seg_type = "line" if on_curve else None + if on_curve: + seg_type = "line" + else: + seg_type = None + pen.addPoint((x, y), segmentType=seg_type if on_curve else None) + pen.endPath() + + try: + glyph = pen.glyph() + except Exception as e: + logger.warning(f"Failed to create glyph {glyph_name}: {e}") + return False + + glyf_table = font["glyf"] + glyf_table[glyph_name] = glyph + + glyph_order = font.getGlyphOrder() + if glyph_name not in glyph_order: + glyph_order.append(glyph_name) + font.setGlyphOrder(glyph_order) + + if "hmtx" in font: + font["hmtx"].metrics[glyph_name] = (advance_width, 0) + + cmap_tables = font["cmap"].tables + for table in cmap_tables: + if hasattr(table, "cmap") and table.cmap is not None: + table.cmap[codepoint] = glyph_name + + font["maxp"].numGlyphs = len(font.getGlyphOrder()) + + return True + + +def add_composite_glyph(font: TTFont, glyph_name: str, codepoint: int, + base_glyph: str, mark_glyphs: list, + advance_width: int) -> bool: + """Add a composite glyph (base + marks) to the font. + + This creates a glyph that references existing base and mark glyphs, + following the Google Fonts approach for Vietnamese characters. + """ + if "glyf" not in font: + return False + + from fontTools.ttLib.tables._g_l_y_f import Glyph + + glyf_table = font["glyf"] + + if base_glyph not in glyf_table: + logger.warning(f"Base glyph '{base_glyph}' not found in font") + return False + + glyph = Glyph() + glyph.numberOfContours = -1 + + from fontTools.ttLib.tables._g_l_y_f import GlyphComponent + + components = [] + + base_component = GlyphComponent() + base_component.glyphName = base_glyph + base_component.flags = 0x0004 | 0x0002 # USE_MY_METRICS | ARGS_ARE_XY_VALUES + base_component.x = 0 + base_component.y = 0 + components.append(base_component) + + metrics = get_font_metrics(font) + x_height = metrics["x_height"] + + for i, mark_glyph in enumerate(mark_glyphs): + if mark_glyph not in glyf_table: + logger.warning(f"Mark glyph '{mark_glyph}' not found, skipping") + continue + mark_component = GlyphComponent() + mark_component.glyphName = mark_glyph + mark_component.flags = 0x0002 # ARGS_ARE_XY_VALUES + mark_component.x = 0 + mark_component.y = 0 + components.append(mark_component) + + glyph.components = components + glyf_table[glyph_name] = glyph + + glyph_order = font.getGlyphOrder() + if glyph_name not in glyph_order: + glyph_order.append(glyph_name) + font.setGlyphOrder(glyph_order) + + if "hmtx" in font: + font["hmtx"].metrics[glyph_name] = (advance_width, 0) + + cmap_tables = font["cmap"].tables + for table in cmap_tables: + if hasattr(table, "cmap") and table.cmap is not None: + table.cmap[codepoint] = glyph_name + + font["maxp"].numGlyphs = len(font.getGlyphOrder()) + + return True + + +def save_font(font: TTFont, output_path: str) -> str: + """Save the modified font to a file.""" + font.save(output_path) + return output_path + + +def save_font_to_bytes(font: TTFont) -> bytes: + """Save the font to bytes (for streaming download).""" + buf = io.BytesIO() + font.save(buf) + buf.seek(0) + return buf.read() + + +def get_glyph_name_for_codepoint(codepoint: int) -> str: + """Generate a standard glyph name for a Unicode codepoint.""" + if codepoint < 0x10000: + return f"uni{codepoint:04X}" + return f"u{codepoint:06X}" diff --git a/viet-font-app/backend/main.py b/viet-font-app/backend/main.py new file mode 100644 index 0000000000..9af712aceb --- /dev/null +++ b/viet-font-app/backend/main.py @@ -0,0 +1,455 @@ +"""FastAPI application for Vietnamese Font Generator. + +This app allows users to upload a font file and sample images, +then uses AI (Gemini or ChatGPT) to analyze the font style and +generate Vietnamese-compatible glyphs. +""" + +import json +import logging +import os +import shutil +import tempfile +import uuid +from pathlib import Path + +from fastapi import FastAPI, File, Form, HTTPException, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse +from fastapi.staticfiles import StaticFiles + +from .ai_client import create_ai_client +from .font_processor import ( + add_composite_glyph, + add_glyph_from_svg, + analyze_missing_chars, + get_font_cmap, + get_font_metrics, + get_glyph_name_for_codepoint, + load_font, + save_font, +) +from .vietnamese import ( + MARK_GLYPH_NAMES, + VIET_PRECOMPOSED, + get_vietnamese_sample_text, +) + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = FastAPI( + title="Vietnamese Font Generator", + description="AI-powered Vietnamese font localization tool", + version="1.0.0", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +UPLOAD_DIR = Path(tempfile.gettempdir()) / "viet-font-uploads" +OUTPUT_DIR = Path(tempfile.gettempdir()) / "viet-font-outputs" +UPLOAD_DIR.mkdir(exist_ok=True) +OUTPUT_DIR.mkdir(exist_ok=True) + +FRONTEND_DIR = Path(__file__).parent.parent / "frontend" +app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static") + + +@app.get("/", response_class=HTMLResponse) +async def root(): + """Serve the frontend.""" + index_path = FRONTEND_DIR / "index.html" + return HTMLResponse(content=index_path.read_text(encoding="utf-8")) + + +@app.post("/api/analyze-font") +async def analyze_font(font_file: UploadFile = File(...)): + """Analyze a font file and return information about missing Vietnamese characters.""" + if not font_file.filename: + raise HTTPException(400, "No font file provided") + + suffix = Path(font_file.filename).suffix.lower() + if suffix not in (".ttf", ".otf", ".woff", ".woff2"): + raise HTTPException(400, "Unsupported font format. Use TTF, OTF, WOFF, or WOFF2.") + + session_id = str(uuid.uuid4()) + session_dir = UPLOAD_DIR / session_id + session_dir.mkdir(exist_ok=True) + + font_path = session_dir / f"input{suffix}" + with open(font_path, "wb") as f: + content = await font_file.read() + f.write(content) + + try: + font = load_font(str(font_path)) + metrics = get_font_metrics(font) + analysis = analyze_missing_chars(font) + font.close() + + return JSONResponse({ + "session_id": session_id, + "font_name": font_file.filename, + "metrics": metrics, + "analysis": { + "total_missing": analysis["total_missing"], + "missing_chars": [ + {"char": m["char"], "codepoint": hex(m["codepoint"])} + for m in analysis["missing_chars"] + ], + "base_chars_present": analysis["base_chars_present"], + "combining_marks_present": analysis["combining_marks_present"], + }, + "sample_text": get_vietnamese_sample_text(), + }) + except Exception as e: + logger.exception("Error analyzing font") + raise HTTPException(500, f"Error analyzing font: {str(e)}") + + +@app.post("/api/generate-font") +async def generate_font( + font_file: UploadFile = File(...), + sample_images: list[UploadFile] = File(default=[]), + ai_provider: str = Form(...), + api_key: str = Form(...), + session_id: str = Form(default=""), +): + """Generate Vietnamese-compatible font using AI analysis. + + Process: + 1. Load the base font + 2. Analyze font style from sample images using AI + 3. Generate missing Vietnamese glyphs + 4. Return the modified font as TTF + """ + if ai_provider not in ("gemini", "openai"): + raise HTTPException(400, "ai_provider must be 'gemini' or 'openai'") + if not api_key: + raise HTTPException(400, "API key is required") + + if not session_id: + session_id = str(uuid.uuid4()) + + session_dir = UPLOAD_DIR / session_id + session_dir.mkdir(exist_ok=True) + + suffix = Path(font_file.filename or "font.ttf").suffix.lower() + font_path = session_dir / f"input{suffix}" + with open(font_path, "wb") as f: + content = await font_file.read() + f.write(content) + + image_paths = [] + for i, img in enumerate(sample_images): + if img.filename: + img_suffix = Path(img.filename).suffix.lower() + img_path = session_dir / f"sample_{i}{img_suffix}" + with open(img_path, "wb") as f: + img_content = await img.read() + f.write(img_content) + image_paths.append(str(img_path)) + + try: + font = load_font(str(font_path)) + metrics = get_font_metrics(font) + analysis = analyze_missing_chars(font) + + if analysis["total_missing"] == 0: + output_path = OUTPUT_DIR / f"{session_id}_viet.ttf" + save_font(font, str(output_path)) + font.close() + return FileResponse( + str(output_path), + media_type="font/ttf", + filename=f"vietnamized_{Path(font_file.filename or 'font').stem}.ttf", + ) + + ai_client = create_ai_client(ai_provider, api_key) + + style_analysis = {} + if image_paths: + logger.info("Analyzing font style from %d sample images...", len(image_paths)) + style_analysis = ai_client.analyze_font_style(image_paths) + logger.info("Style analysis: %s", json.dumps(style_analysis, indent=2)) + else: + style_analysis = { + "weight": "regular", + "contrast": "low", + "serif_style": "sans-serif", + "construction": "geometric", + "terminals": "round", + "x_height": "medium", + "style_notes": "Standard sans-serif font", + } + + cmap = get_font_cmap(font) + generated_count = 0 + failed_count = 0 + + for missing in analysis["missing_chars"]: + char = missing["char"] + codepoint = missing["codepoint"] + base = missing["base"] + marks = missing["marks"] + + glyph_name = get_glyph_name_for_codepoint(codepoint) + + if base and marks: + base_codepoint = ord(base) + if base_codepoint in cmap: + base_glyph = cmap[base_codepoint] + mark_glyphs = [] + all_marks_available = True + + for mark_name in marks: + mark_glyph_name = MARK_GLYPH_NAMES.get(mark_name, "") + if mark_glyph_name and mark_glyph_name in font.getGlyphOrder(): + mark_glyphs.append(mark_glyph_name) + else: + all_marks_available = False + break + + if all_marks_available and mark_glyphs: + base_width = 0 + if "hmtx" in font and base_glyph in font["hmtx"].metrics: + base_width = font["hmtx"].metrics[base_glyph][0] + else: + base_width = metrics["avg_width"] + + success = add_composite_glyph( + font, glyph_name, codepoint, + base_glyph, mark_glyphs, base_width, + ) + if success: + generated_count += 1 + logger.info(f"Added composite glyph: {char} (U+{codepoint:04X})") + continue + + logger.info(f"Generating AI glyph for: {char} (U+{codepoint:04X})") + try: + from .ai_client import FULL_CHAR_GENERATION_PROMPT + prompt = FULL_CHAR_GENERATION_PROMPT.format( + style_analysis=json.dumps(style_analysis), + char=char, + codepoint=codepoint, + base=base or char, + marks=", ".join(marks) if marks else "none", + upm=metrics["upm"], + ascender=metrics["ascender"], + descender=metrics["descender"], + avg_width=metrics["avg_width"], + ) + glyph_data = ai_client.generate_glyph(prompt) + + svg_path = glyph_data.get("svg_path", "") + width = glyph_data.get("width", metrics["avg_width"]) + + if svg_path: + success = add_glyph_from_svg( + font, glyph_name, codepoint, + svg_path, int(width), + ) + if success: + generated_count += 1 + logger.info(f"Added AI glyph: {char} (U+{codepoint:04X})") + else: + failed_count += 1 + logger.warning(f"Failed to add glyph: {char}") + else: + failed_count += 1 + logger.warning(f"No SVG path returned for: {char}") + + except Exception as e: + failed_count += 1 + logger.warning(f"AI generation failed for {char}: {e}") + + output_path = OUTPUT_DIR / f"{session_id}_viet.ttf" + save_font(font, str(output_path)) + font.close() + + logger.info( + f"Font generation complete: {generated_count} generated, " + f"{failed_count} failed out of {analysis['total_missing']} missing" + ) + + return FileResponse( + str(output_path), + media_type="font/ttf", + filename=f"vietnamized_{Path(font_file.filename or 'font').stem}.ttf", + headers={ + "X-Generated-Count": str(generated_count), + "X-Failed-Count": str(failed_count), + "X-Total-Missing": str(analysis["total_missing"]), + }, + ) + + except HTTPException: + raise + except Exception as e: + logger.exception("Error generating font") + raise HTTPException(500, f"Error generating Vietnamese font: {str(e)}") + + +@app.post("/api/generate-font-stream") +async def generate_font_stream( + font_file: UploadFile = File(...), + sample_images: list[UploadFile] = File(default=[]), + ai_provider: str = Form(...), + api_key: str = Form(...), +): + """Generate font with progress updates via JSON response. + + Returns a JSON response with the download URL and generation statistics. + """ + if ai_provider not in ("gemini", "openai"): + raise HTTPException(400, "ai_provider must be 'gemini' or 'openai'") + + session_id = str(uuid.uuid4()) + session_dir = UPLOAD_DIR / session_id + session_dir.mkdir(exist_ok=True) + + suffix = Path(font_file.filename or "font.ttf").suffix.lower() + font_path = session_dir / f"input{suffix}" + with open(font_path, "wb") as f: + content = await font_file.read() + f.write(content) + + image_paths = [] + for i, img in enumerate(sample_images): + if img.filename: + img_suffix = Path(img.filename).suffix.lower() + img_path = session_dir / f"sample_{i}{img_suffix}" + with open(img_path, "wb") as f: + img_content = await img.read() + f.write(img_content) + image_paths.append(str(img_path)) + + try: + font = load_font(str(font_path)) + metrics = get_font_metrics(font) + analysis = analyze_missing_chars(font) + ai_client = create_ai_client(ai_provider, api_key) + + style_analysis = {} + if image_paths: + style_analysis = ai_client.analyze_font_style(image_paths) + else: + style_analysis = { + "weight": "regular", "contrast": "low", + "serif_style": "sans-serif", "construction": "geometric", + "terminals": "round", "x_height": "medium", + "style_notes": "Standard font", + } + + cmap = get_font_cmap(font) + results = [] + generated_count = 0 + failed_count = 0 + + for missing in analysis["missing_chars"]: + char = missing["char"] + codepoint = missing["codepoint"] + base = missing["base"] + marks = missing["marks"] + glyph_name = get_glyph_name_for_codepoint(codepoint) + + if base and marks: + base_codepoint = ord(base) + if base_codepoint in cmap: + base_glyph = cmap[base_codepoint] + mark_glyphs = [] + all_marks = True + for mn in marks: + mgn = MARK_GLYPH_NAMES.get(mn, "") + if mgn and mgn in font.getGlyphOrder(): + mark_glyphs.append(mgn) + else: + all_marks = False + break + if all_marks and mark_glyphs: + bw = font["hmtx"].metrics.get(base_glyph, (metrics["avg_width"], 0))[0] if "hmtx" in font else metrics["avg_width"] + if add_composite_glyph(font, glyph_name, codepoint, base_glyph, mark_glyphs, bw): + generated_count += 1 + results.append({"char": char, "codepoint": hex(codepoint), "status": "composite"}) + continue + + try: + from .ai_client import FULL_CHAR_GENERATION_PROMPT + prompt = FULL_CHAR_GENERATION_PROMPT.format( + style_analysis=json.dumps(style_analysis), + char=char, codepoint=codepoint, + base=base or char, marks=", ".join(marks) if marks else "none", + upm=metrics["upm"], ascender=metrics["ascender"], + descender=metrics["descender"], avg_width=metrics["avg_width"], + ) + glyph_data = ai_client.generate_glyph(prompt) + svg_path = glyph_data.get("svg_path", "") + width = glyph_data.get("width", metrics["avg_width"]) + if svg_path and add_glyph_from_svg(font, glyph_name, codepoint, svg_path, int(width)): + generated_count += 1 + results.append({"char": char, "codepoint": hex(codepoint), "status": "ai_generated"}) + else: + failed_count += 1 + results.append({"char": char, "codepoint": hex(codepoint), "status": "failed"}) + except Exception as e: + failed_count += 1 + results.append({"char": char, "codepoint": hex(codepoint), "status": "failed", "error": str(e)}) + + output_path = OUTPUT_DIR / f"{session_id}_viet.ttf" + save_font(font, str(output_path)) + font.close() + + return JSONResponse({ + "session_id": session_id, + "download_url": f"/api/download/{session_id}", + "stats": { + "total_missing": analysis["total_missing"], + "generated": generated_count, + "failed": failed_count, + }, + "results": results, + }) + + except HTTPException: + raise + except Exception as e: + logger.exception("Error in font generation stream") + raise HTTPException(500, f"Error: {str(e)}") + + +@app.get("/api/download/{session_id}") +async def download_font(session_id: str): + """Download a previously generated font.""" + output_path = OUTPUT_DIR / f"{session_id}_viet.ttf" + if not output_path.exists(): + raise HTTPException(404, "Generated font not found. It may have expired.") + return FileResponse( + str(output_path), + media_type="font/ttf", + filename=f"vietnamized_font_{session_id[:8]}.ttf", + ) + + +@app.delete("/api/cleanup/{session_id}") +async def cleanup_session(session_id: str): + """Clean up temporary files for a session.""" + session_dir = UPLOAD_DIR / session_id + if session_dir.exists(): + shutil.rmtree(session_dir) + output_path = OUTPUT_DIR / f"{session_id}_viet.ttf" + if output_path.exists(): + output_path.unlink() + return {"status": "cleaned"} + + +@app.get("/api/health") +async def health(): + """Health check endpoint.""" + return {"status": "ok", "version": "1.0.0"} diff --git a/viet-font-app/backend/vietnamese.py b/viet-font-app/backend/vietnamese.py new file mode 100644 index 0000000000..f6af67a95d --- /dev/null +++ b/viet-font-app/backend/vietnamese.py @@ -0,0 +1,226 @@ +"""Vietnamese character data and Unicode mappings. + +This module contains the complete set of Vietnamese characters that need +to be present in a Vietnamese-compatible font, following the Google Fonts +diacritics guidelines for proper anchor-based mark positioning. +""" + +# Vietnamese-specific base characters (beyond standard Latin) +VIET_BASE_CHARS = { + "Ă": 0x0102, "ă": 0x0103, # A with breve + "Â": 0x00C2, "â": 0x00E2, # A with circumflex + "Đ": 0x0110, "đ": 0x0111, # D with stroke + "Ê": 0x00CA, "ê": 0x00EA, # E with circumflex + "Ô": 0x00D4, "ô": 0x00F4, # O with circumflex + "Ơ": 0x01A0, "ơ": 0x01A1, # O with horn + "Ư": 0x01AF, "ư": 0x01B0, # U with horn +} + +# Combining diacritical marks used in Vietnamese +COMBINING_MARKS = { + "acute": 0x0301, # sắc + "grave": 0x0300, # huyền + "hook_above": 0x0309, # hỏi + "tilde": 0x0303, # ngã + "dot_below": 0x0323, # nặng + "breve": 0x0306, + "circumflex": 0x0302, + "horn": 0x031B, +} + +# Full Vietnamese precomposed characters +# Format: (character, unicode, base_char, combining_marks[]) +VIET_PRECOMPOSED = [ + # A with tone marks + ("À", 0x00C0, "A", ["grave"]), + ("Á", 0x00C1, "A", ["acute"]), + ("Ả", 0x1EA2, "A", ["hook_above"]), + ("Ã", 0x00C3, "A", ["tilde"]), + ("Ạ", 0x1EA0, "A", ["dot_below"]), + ("à", 0x00E0, "a", ["grave"]), + ("á", 0x00E1, "a", ["acute"]), + ("ả", 0x1EA3, "a", ["hook_above"]), + ("ã", 0x00E3, "a", ["tilde"]), + ("ạ", 0x1EA1, "a", ["dot_below"]), + # A breve with tone marks + ("Ắ", 0x1EAE, "Ă", ["acute"]), + ("Ằ", 0x1EB0, "Ă", ["grave"]), + ("Ẳ", 0x1EB2, "Ă", ["hook_above"]), + ("Ẵ", 0x1EB4, "Ă", ["tilde"]), + ("Ặ", 0x1EB6, "Ă", ["dot_below"]), + ("ắ", 0x1EAF, "ă", ["acute"]), + ("ằ", 0x1EB1, "ă", ["grave"]), + ("ẳ", 0x1EB3, "ă", ["hook_above"]), + ("ẵ", 0x1EB5, "ă", ["tilde"]), + ("ặ", 0x1EB7, "ă", ["dot_below"]), + # A circumflex with tone marks + ("Ấ", 0x1EA4, "Â", ["acute"]), + ("Ầ", 0x1EA6, "Â", ["grave"]), + ("Ẩ", 0x1EA8, "Â", ["hook_above"]), + ("Ẫ", 0x1EAA, "Â", ["tilde"]), + ("Ậ", 0x1EAC, "Â", ["dot_below"]), + ("ấ", 0x1EA5, "â", ["acute"]), + ("ầ", 0x1EA7, "â", ["grave"]), + ("ẩ", 0x1EA9, "â", ["hook_above"]), + ("ẫ", 0x1EAB, "â", ["tilde"]), + ("ậ", 0x1EAD, "â", ["dot_below"]), + # E with tone marks + ("È", 0x00C8, "E", ["grave"]), + ("É", 0x00C9, "E", ["acute"]), + ("Ẻ", 0x1EBA, "E", ["hook_above"]), + ("Ẽ", 0x1EBC, "E", ["tilde"]), + ("Ẹ", 0x1EB8, "E", ["dot_below"]), + ("è", 0x00E8, "e", ["grave"]), + ("é", 0x00E9, "e", ["acute"]), + ("ẻ", 0x1EBB, "e", ["hook_above"]), + ("ẽ", 0x1EBD, "e", ["tilde"]), + ("ẹ", 0x1EB9, "e", ["dot_below"]), + # E circumflex with tone marks + ("Ế", 0x1EBE, "Ê", ["acute"]), + ("Ề", 0x1EC0, "Ê", ["grave"]), + ("Ể", 0x1EC2, "Ê", ["hook_above"]), + ("Ễ", 0x1EC4, "Ê", ["tilde"]), + ("Ệ", 0x1EC6, "Ê", ["dot_below"]), + ("ế", 0x1EBF, "ê", ["acute"]), + ("ề", 0x1EC1, "ê", ["grave"]), + ("ể", 0x1EC3, "ê", ["hook_above"]), + ("ễ", 0x1EC5, "ê", ["tilde"]), + ("ệ", 0x1EC7, "ê", ["dot_below"]), + # I with tone marks + ("Ì", 0x00CC, "I", ["grave"]), + ("Í", 0x00CD, "I", ["acute"]), + ("Ỉ", 0x1EC8, "I", ["hook_above"]), + ("Ĩ", 0x0128, "I", ["tilde"]), + ("Ị", 0x1ECA, "I", ["dot_below"]), + ("ì", 0x00EC, "i", ["grave"]), + ("í", 0x00ED, "i", ["acute"]), + ("ỉ", 0x1EC9, "i", ["hook_above"]), + ("ĩ", 0x0129, "i", ["tilde"]), + ("ị", 0x1ECB, "i", ["dot_below"]), + # O with tone marks + ("Ò", 0x00D2, "O", ["grave"]), + ("Ó", 0x00D3, "O", ["acute"]), + ("Ỏ", 0x1ECE, "O", ["hook_above"]), + ("Õ", 0x00D5, "O", ["tilde"]), + ("Ọ", 0x1ECC, "O", ["dot_below"]), + ("ò", 0x00F2, "o", ["grave"]), + ("ó", 0x00F3, "o", ["acute"]), + ("ỏ", 0x1ECF, "o", ["hook_above"]), + ("õ", 0x00F5, "o", ["tilde"]), + ("ọ", 0x1ECD, "o", ["dot_below"]), + # O circumflex with tone marks + ("Ố", 0x1ED0, "Ô", ["acute"]), + ("Ồ", 0x1ED2, "Ô", ["grave"]), + ("Ổ", 0x1ED4, "Ô", ["hook_above"]), + ("Ỗ", 0x1ED6, "Ô", ["tilde"]), + ("Ộ", 0x1ED8, "Ô", ["dot_below"]), + ("ố", 0x1ED1, "ô", ["acute"]), + ("ồ", 0x1ED3, "ô", ["grave"]), + ("ổ", 0x1ED5, "ô", ["hook_above"]), + ("ỗ", 0x1ED7, "ô", ["tilde"]), + ("ộ", 0x1ED9, "ô", ["dot_below"]), + # O horn with tone marks + ("Ớ", 0x1EDA, "Ơ", ["acute"]), + ("Ờ", 0x1EDC, "Ơ", ["grave"]), + ("Ở", 0x1EDE, "Ơ", ["hook_above"]), + ("Ỡ", 0x1EE0, "Ơ", ["tilde"]), + ("Ợ", 0x1EE2, "Ơ", ["dot_below"]), + ("ớ", 0x1EDB, "ơ", ["acute"]), + ("ờ", 0x1EDD, "ơ", ["grave"]), + ("ở", 0x1EDF, "ơ", ["hook_above"]), + ("ỡ", 0x1EE1, "ơ", ["tilde"]), + ("ợ", 0x1EE3, "ơ", ["dot_below"]), + # U with tone marks + ("Ù", 0x00D9, "U", ["grave"]), + ("Ú", 0x00DA, "U", ["acute"]), + ("Ủ", 0x1EE6, "U", ["hook_above"]), + ("Ũ", 0x0168, "U", ["tilde"]), + ("Ụ", 0x1EE4, "U", ["dot_below"]), + ("ù", 0x00F9, "u", ["grave"]), + ("ú", 0x00FA, "u", ["acute"]), + ("ủ", 0x1EE7, "u", ["hook_above"]), + ("ũ", 0x0169, "u", ["tilde"]), + ("ụ", 0x1EE5, "u", ["dot_below"]), + # U horn with tone marks + ("Ứ", 0x1EE8, "Ư", ["acute"]), + ("Ừ", 0x1EEA, "Ư", ["grave"]), + ("Ử", 0x1EEC, "Ư", ["hook_above"]), + ("Ữ", 0x1EEE, "Ư", ["tilde"]), + ("Ự", 0x1EF0, "Ư", ["dot_below"]), + ("ứ", 0x1EE9, "ư", ["acute"]), + ("ừ", 0x1EEB, "ư", ["grave"]), + ("ử", 0x1EED, "ư", ["hook_above"]), + ("ữ", 0x1EEF, "ư", ["tilde"]), + ("ự", 0x1EF1, "ư", ["dot_below"]), + # Y with tone marks + ("Ỳ", 0x1EF2, "Y", ["grave"]), + ("Ý", 0x00DD, "Y", ["acute"]), + ("Ỷ", 0x1EF6, "Y", ["hook_above"]), + ("Ỹ", 0x1EF8, "Y", ["tilde"]), + ("Ỵ", 0x1EF4, "Y", ["dot_below"]), + ("ỳ", 0x1EF3, "y", ["grave"]), + ("ý", 0x00FD, "y", ["acute"]), + ("ỷ", 0x1EF7, "y", ["hook_above"]), + ("ỹ", 0x1EF9, "y", ["tilde"]), + ("ỵ", 0x1EF5, "y", ["dot_below"]), +] + +# All Vietnamese Unicode codepoints that should be in a complete Vietnamese font +ALL_VIET_CODEPOINTS = set() +for _char, _cp, _base, _marks in VIET_PRECOMPOSED: + ALL_VIET_CODEPOINTS.add(_cp) +for _char, _cp in VIET_BASE_CHARS.items(): + ALL_VIET_CODEPOINTS.add(_cp) + +# Anchor names following Google Fonts guidelines +ANCHOR_NAMES = { + "top": "top", + "bottom": "bottom", + "top_viet": "top_viet", # Custom anchor for Vietnamese stacked diacritics +} + +# Mark glyph name suffixes following Google Fonts naming conventions +MARK_GLYPH_NAMES = { + "acute": "acutecomb", + "grave": "gravecomb", + "hook_above": "hookabovecomb", + "tilde": "tildecomb", + "dot_below": "dotbelowcomb", + "breve": "brevecomb", + "circumflex": "circumflexcomb", + "horn": "horncomb", +} + + +def get_missing_vietnamese_chars(font_cmap: dict) -> list: + """Given a font's cmap (unicode -> glyph name), return missing Vietnamese chars.""" + missing = [] + for char, codepoint, base, marks in VIET_PRECOMPOSED: + if codepoint not in font_cmap: + missing.append({ + "char": char, + "codepoint": codepoint, + "base": base, + "marks": marks, + }) + for char, codepoint in VIET_BASE_CHARS.items(): + if codepoint not in font_cmap: + missing.append({ + "char": char, + "codepoint": codepoint, + "base": None, + "marks": [], + }) + return missing + + +def get_vietnamese_sample_text() -> str: + """Return a sample Vietnamese text for testing.""" + return ( + "Việt Nam đất nước tôi yêu. " + "Hà Nội là thủ đô của Việt Nam. " + "Thành phố Hồ Chí Minh rực rỡ ánh đèn. " + "Đà Nẵng xinh đẹp bên bờ biển. " + "Ăn cơm chưa? Ừ, ăn rồi. " + "ẮẰẲẴẶẤẦẨẪẬẾỀỂỄỆỐỒỔỖỘỚỜỞỠỢỨỪỬỮỰ" + ) diff --git a/viet-font-app/frontend/app.js b/viet-font-app/frontend/app.js new file mode 100644 index 0000000000..8a377a90e2 --- /dev/null +++ b/viet-font-app/frontend/app.js @@ -0,0 +1,293 @@ +/* Vietnamese Font Generator - Frontend Application */ + +let fontFile = null; +let sampleImages = []; +let currentSessionId = null; +let downloadUrl = null; + +/* --- Initialization --- */ +document.addEventListener("DOMContentLoaded", () => { + setupFontUpload(); + setupImageUpload(); + updateGenerateButton(); +}); + +/* --- API Key --- */ +function toggleApiKey() { + const input = document.getElementById("api_key"); + const btn = input.nextElementSibling; + if (input.type === "password") { + input.type = "text"; + btn.textContent = "Hide"; + } else { + input.type = "password"; + btn.textContent = "Show"; + } +} + +/* --- Font Upload --- */ +function setupFontUpload() { + const area = document.getElementById("font-upload-area"); + const input = document.getElementById("font_file"); + + area.addEventListener("click", () => input.click()); + area.addEventListener("dragover", (e) => { + e.preventDefault(); + area.classList.add("dragover"); + }); + area.addEventListener("dragleave", () => area.classList.remove("dragover")); + area.addEventListener("drop", (e) => { + e.preventDefault(); + area.classList.remove("dragover"); + if (e.dataTransfer.files.length > 0) { + handleFontFile(e.dataTransfer.files[0]); + } + }); + input.addEventListener("change", () => { + if (input.files.length > 0) { + handleFontFile(input.files[0]); + } + }); +} + +function handleFontFile(file) { + const validExts = [".ttf", ".otf", ".woff", ".woff2"]; + const ext = file.name.substring(file.name.lastIndexOf(".")).toLowerCase(); + if (!validExts.includes(ext)) { + alert("Please upload a font file (.ttf, .otf, .woff, .woff2)"); + return; + } + + fontFile = file; + document.getElementById("font-file-name").textContent = file.name + " (" + formatSize(file.size) + ")"; + document.getElementById("font-file-info").hidden = false; + document.querySelector("#font-upload-area .upload-content").style.display = "none"; + + updateGenerateButton(); + analyzeFont(); +} + +function removeFont() { + fontFile = null; + document.getElementById("font-file-info").hidden = true; + document.querySelector("#font-upload-area .upload-content").style.display = ""; + document.getElementById("font_file").value = ""; + document.getElementById("font-analysis").hidden = true; + currentSessionId = null; + updateGenerateButton(); +} + +async function analyzeFont() { + if (!fontFile) return; + + const formData = new FormData(); + formData.append("font_file", fontFile); + + try { + const response = await fetch("/api/analyze-font", { + method: "POST", + body: formData, + }); + + if (!response.ok) { + const err = await response.json(); + throw new Error(err.detail || "Analysis failed"); + } + + const data = await response.json(); + currentSessionId = data.session_id; + + document.getElementById("total-glyphs").textContent = data.metrics.num_glyphs; + document.getElementById("font-upm").textContent = data.metrics.upm; + document.getElementById("missing-count").textContent = data.analysis.total_missing; + + const preview = document.getElementById("missing-chars-preview"); + if (data.analysis.missing_chars.length > 0) { + preview.textContent = data.analysis.missing_chars.map((c) => c.char).join(" "); + } else { + preview.textContent = "All Vietnamese characters are present!"; + preview.style.color = "var(--success)"; + } + + document.getElementById("font-analysis").hidden = false; + } catch (error) { + console.error("Font analysis error:", error); + alert("Error analyzing font: " + error.message); + } +} + +/* --- Image Upload --- */ +function setupImageUpload() { + const area = document.getElementById("image-upload-area"); + const input = document.getElementById("sample_images"); + + area.addEventListener("click", () => input.click()); + area.addEventListener("dragover", (e) => { + e.preventDefault(); + area.classList.add("dragover"); + }); + area.addEventListener("dragleave", () => area.classList.remove("dragover")); + area.addEventListener("drop", (e) => { + e.preventDefault(); + area.classList.remove("dragover"); + handleImageFiles(e.dataTransfer.files); + }); + input.addEventListener("change", () => { + handleImageFiles(input.files); + }); +} + +function handleImageFiles(files) { + for (const file of files) { + if (!file.type.startsWith("image/")) continue; + sampleImages.push(file); + addImagePreview(file, sampleImages.length - 1); + } +} + +function addImagePreview(file, index) { + const container = document.getElementById("image-previews"); + const div = document.createElement("div"); + div.className = "image-preview"; + div.id = "img-preview-" + index; + + const img = document.createElement("img"); + img.src = URL.createObjectURL(file); + img.alt = file.name; + + const removeBtn = document.createElement("button"); + removeBtn.className = "remove-image"; + removeBtn.textContent = "X"; + removeBtn.onclick = () => removeImage(index); + + div.appendChild(img); + div.appendChild(removeBtn); + container.appendChild(div); +} + +function removeImage(index) { + sampleImages[index] = null; + const el = document.getElementById("img-preview-" + index); + if (el) el.remove(); +} + +/* --- Generate Font --- */ +function updateGenerateButton() { + const btn = document.getElementById("generate-btn"); + const apiKey = document.getElementById("api_key").value; + btn.disabled = !fontFile || !apiKey; +} + +document.getElementById("api_key").addEventListener("input", updateGenerateButton); + +async function generateFont() { + const apiKey = document.getElementById("api_key").value; + const provider = document.querySelector('input[name="ai_provider"]:checked').value; + + if (!fontFile) { + alert("Please upload a font file"); + return; + } + if (!apiKey) { + alert("Please enter your API key"); + return; + } + + const btn = document.getElementById("generate-btn"); + btn.disabled = true; + btn.textContent = "Generating..."; + + const progressContainer = document.getElementById("progress-container"); + progressContainer.hidden = false; + setProgress(10, "Uploading font file..."); + + const formData = new FormData(); + formData.append("font_file", fontFile); + formData.append("ai_provider", provider); + formData.append("api_key", apiKey); + + const activeImages = sampleImages.filter((img) => img !== null); + for (const img of activeImages) { + formData.append("sample_images", img); + } + + setProgress(20, "Analyzing font and generating Vietnamese glyphs..."); + + try { + const response = await fetch("/api/generate-font-stream", { + method: "POST", + body: formData, + }); + + setProgress(80, "Processing AI-generated glyphs..."); + + if (!response.ok) { + const err = await response.json(); + throw new Error(err.detail || "Generation failed"); + } + + const data = await response.json(); + setProgress(100, "Complete!"); + + downloadUrl = data.download_url; + showResults(data); + } catch (error) { + console.error("Generation error:", error); + alert("Error generating font: " + error.message); + setProgress(0, "Failed"); + } finally { + btn.disabled = false; + btn.textContent = "Generate Vietnamese Font"; + } +} + +function setProgress(percent, text) { + document.getElementById("progress-fill").style.width = percent + "%"; + document.getElementById("progress-text").textContent = text; +} + +function showResults(data) { + document.getElementById("result-generated").textContent = data.stats.generated; + document.getElementById("result-failed").textContent = data.stats.failed; + document.getElementById("result-total").textContent = data.stats.total_missing; + + const details = document.getElementById("result-details"); + if (data.results && data.results.length > 0) { + let html = ""; + for (const r of data.results) { + html += ` + + + + `; + } + html += "
CharacterCodepointStatus
${r.char}${r.codepoint}${formatStatus(r.status)}
"; + details.innerHTML = html; + } + + document.getElementById("step-results").hidden = false; + document.getElementById("step-results").scrollIntoView({ behavior: "smooth" }); +} + +function formatStatus(status) { + const map = { + composite: "Composite", + ai_generated: "AI Generated", + failed: "Failed", + }; + return map[status] || status; +} + +/* --- Download --- */ +function downloadFont() { + if (downloadUrl) { + window.location.href = downloadUrl; + } +} + +/* --- Utilities --- */ +function formatSize(bytes) { + if (bytes < 1024) return bytes + " B"; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"; + return (bytes / (1024 * 1024)).toFixed(1) + " MB"; +} diff --git a/viet-font-app/frontend/index.html b/viet-font-app/frontend/index.html new file mode 100644 index 0000000000..c1451f5bd5 --- /dev/null +++ b/viet-font-app/frontend/index.html @@ -0,0 +1,160 @@ + + + + + + Vietnamese Font Generator - AI Font Vietnamization + + + +
+
+

Vietnamese Font Generator

+

AI-powered Vietnamese font localization tool

+

+ Upload your font file and sample images. AI will analyze the font style + and generate Vietnamese diacritical marks following + + Google Fonts diacritics guidelines + . +

+
+ +
+ +
+

1 Choose AI Provider

+
+ + +
+
+ + + +
+
+ + +
+

2 Upload Font File

+
+ +
+
📄
+

Drag & drop your font file here

+

or click to browse (.ttf, .otf, .woff, .woff2)

+
+ +
+ + +
+ + +
+

3 Upload Sample Images (Optional)

+

+ Upload images showing the font style you want to match. + AI will analyze these to understand stroke weight, contrast, and design features. +

+
+ +
+
🖼
+

Drag & drop sample images here

+

or click to browse (PNG, JPG, WEBP)

+
+
+
+
+ + +
+

4 Generate Vietnamese Font

+ + +
+ + + +
+ + +
+ + + + diff --git a/viet-font-app/frontend/style.css b/viet-font-app/frontend/style.css new file mode 100644 index 0000000000..4ff835d618 --- /dev/null +++ b/viet-font-app/frontend/style.css @@ -0,0 +1,573 @@ +:root { + --primary: #2563eb; + --primary-dark: #1d4ed8; + --primary-light: #dbeafe; + --success: #16a34a; + --success-light: #dcfce7; + --warning: #d97706; + --warning-light: #fef3c7; + --danger: #dc2626; + --danger-light: #fee2e2; + --info: #0891b2; + --info-light: #cffafe; + --bg: #f8fafc; + --surface: #ffffff; + --text: #1e293b; + --text-secondary: #64748b; + --border: #e2e8f0; + --radius: 12px; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background-color: var(--bg); + color: var(--text); + line-height: 1.6; +} + +.container { + max-width: 800px; + margin: 0 auto; + padding: 2rem 1rem; +} + +header { + text-align: center; + margin-bottom: 2rem; +} + +header h1 { + font-size: 2rem; + font-weight: 700; + color: var(--text); + margin-bottom: 0.5rem; +} + +.subtitle { + font-size: 1.1rem; + color: var(--primary); + font-weight: 500; +} + +.description { + margin-top: 0.75rem; + color: var(--text-secondary); + font-size: 0.95rem; +} + +.description a { + color: var(--primary); + text-decoration: none; +} + +.description a:hover { + text-decoration: underline; +} + +.step { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.5rem; + margin-bottom: 1.5rem; + box-shadow: var(--shadow); +} + +.step h2 { + font-size: 1.15rem; + font-weight: 600; + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.step-number { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: var(--primary); + color: white; + border-radius: 50%; + font-size: 0.85rem; + font-weight: 700; +} + +.step-description { + color: var(--text-secondary); + font-size: 0.9rem; + margin-bottom: 1rem; +} + +.optional { + font-size: 0.8rem; + color: var(--text-secondary); + font-weight: 400; +} + +/* Provider Selector */ +.provider-selector { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-bottom: 1rem; +} + +.provider-option input[type="radio"] { + display: none; +} + +.provider-card { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + border: 2px solid var(--border); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; +} + +.provider-option input:checked + .provider-card { + border-color: var(--primary); + background: var(--primary-light); +} + +.provider-card:hover { + border-color: var(--primary); +} + +.provider-icon { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: var(--primary); + color: white; + border-radius: 8px; + font-weight: 700; + font-size: 1.2rem; +} + +.provider-info { + display: flex; + flex-direction: column; +} + +.provider-info strong { + font-size: 0.95rem; +} + +.provider-info span { + font-size: 0.8rem; + color: var(--text-secondary); +} + +/* API Key Input */ +.api-key-input { + display: flex; + gap: 0.5rem; + align-items: end; +} + +.api-key-input label { + display: block; + font-size: 0.85rem; + font-weight: 500; + margin-bottom: 0.25rem; + color: var(--text-secondary); +} + +.api-key-input input { + flex: 1; + padding: 0.6rem 0.8rem; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.9rem; + outline: none; + transition: border-color 0.2s; +} + +.api-key-input input:focus { + border-color: var(--primary); +} + +.toggle-visibility { + padding: 0.6rem 1rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; + color: var(--text-secondary); +} + +.toggle-visibility:hover { + background: var(--border); +} + +/* Upload Area */ +.upload-area { + border: 2px dashed var(--border); + border-radius: 8px; + padding: 2rem; + text-align: center; + cursor: pointer; + transition: all 0.2s; +} + +.upload-area:hover, +.upload-area.dragover { + border-color: var(--primary); + background: var(--primary-light); +} + +.upload-icon { + font-size: 2.5rem; + margin-bottom: 0.5rem; +} + +.upload-content p { + color: var(--text-secondary); +} + +.upload-hint { + font-size: 0.8rem !important; + margin-top: 0.25rem; +} + +.file-info { + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + background: var(--primary-light); + border-radius: 6px; + margin-top: 0.75rem; +} + +.file-info:not([hidden]) { + display: flex; +} + +.file-name { + font-weight: 500; + font-size: 0.9rem; +} + +.remove-file { + padding: 0.3rem 0.6rem; + background: none; + border: 1px solid var(--danger); + color: var(--danger); + border-radius: 4px; + cursor: pointer; + font-size: 0.8rem; +} + +.remove-file:hover { + background: var(--danger-light); +} + +/* Analysis Results */ +.analysis-results { + margin-top: 1rem; + padding: 1rem; + background: var(--bg); + border-radius: 8px; +} + +.analysis-results h3 { + font-size: 0.95rem; + margin-bottom: 0.75rem; +} + +.analysis-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.analysis-item { + text-align: center; + padding: 0.5rem; + background: var(--surface); + border-radius: 6px; + border: 1px solid var(--border); +} + +.analysis-label { + display: block; + font-size: 0.75rem; + color: var(--text-secondary); + margin-bottom: 0.25rem; +} + +.analysis-value { + display: block; + font-size: 1.2rem; + font-weight: 700; +} + +.analysis-value.highlight { + color: var(--warning); +} + +.missing-chars-preview { + max-height: 120px; + overflow-y: auto; + font-size: 1.2rem; + line-height: 2; + letter-spacing: 0.5em; + color: var(--danger); + word-break: break-all; + padding: 0.5rem; + background: var(--surface); + border-radius: 6px; + border: 1px solid var(--border); +} + +/* Image Previews */ +.image-previews { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 0.75rem; +} + +.image-preview { + position: relative; + width: 100px; + height: 100px; + border-radius: 6px; + overflow: hidden; + border: 1px solid var(--border); +} + +.image-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.image-preview .remove-image { + position: absolute; + top: 4px; + right: 4px; + width: 20px; + height: 20px; + background: rgba(220, 38, 38, 0.8); + color: white; + border: none; + border-radius: 50%; + cursor: pointer; + font-size: 0.7rem; + display: flex; + align-items: center; + justify-content: center; +} + +/* Generate Button */ +.generate-button { + width: 100%; + padding: 1rem; + background: var(--primary); + color: white; + border: none; + border-radius: 8px; + font-size: 1.1rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} + +.generate-button:hover:not(:disabled) { + background: var(--primary-dark); +} + +.generate-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Progress */ +.progress-container { + margin-top: 1rem; +} + +.progress-bar { + width: 100%; + height: 8px; + background: var(--border); + border-radius: 4px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: var(--primary); + border-radius: 4px; + width: 0%; + transition: width 0.3s; + animation: pulse 2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +.progress-text { + margin-top: 0.5rem; + font-size: 0.85rem; + color: var(--text-secondary); + text-align: center; +} + +/* Results */ +.results-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.75rem; + margin-bottom: 1rem; +} + +.result-item { + text-align: center; + padding: 0.75rem; + border-radius: 8px; +} + +.result-item.success { + background: var(--success-light); +} + +.result-item.warning { + background: var(--warning-light); +} + +.result-item.info { + background: var(--info-light); +} + +.result-label { + display: block; + font-size: 0.8rem; + color: var(--text-secondary); +} + +.result-value { + display: block; + font-size: 1.5rem; + font-weight: 700; +} + +.download-button { + width: 100%; + padding: 1rem; + background: var(--success); + color: white; + border: none; + border-radius: 8px; + font-size: 1.1rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} + +.download-button:hover { + background: #15803d; +} + +.result-details { + margin-top: 1rem; + max-height: 200px; + overflow-y: auto; + font-size: 0.85rem; +} + +.result-details table { + width: 100%; + border-collapse: collapse; +} + +.result-details th, +.result-details td { + padding: 0.4rem 0.6rem; + border-bottom: 1px solid var(--border); + text-align: left; +} + +.result-details th { + background: var(--bg); + font-weight: 600; + position: sticky; + top: 0; +} + +.status-composite { + color: var(--success); + font-weight: 500; +} + +.status-ai_generated { + color: var(--primary); + font-weight: 500; +} + +.status-failed { + color: var(--danger); + font-weight: 500; +} + +footer { + text-align: center; + padding: 2rem 0; + color: var(--text-secondary); + font-size: 0.85rem; +} + +footer a { + color: var(--primary); + text-decoration: none; +} + +footer a:hover { + text-decoration: underline; +} + +/* Responsive */ +@media (max-width: 600px) { + .container { + padding: 1rem 0.5rem; + } + + .provider-selector { + grid-template-columns: 1fr; + } + + .analysis-grid, + .results-grid { + grid-template-columns: 1fr; + } + + .api-key-input { + flex-direction: column; + align-items: stretch; + } + + header h1 { + font-size: 1.5rem; + } +} diff --git a/viet-font-app/requirements.txt b/viet-font-app/requirements.txt new file mode 100644 index 0000000000..25d71274a1 --- /dev/null +++ b/viet-font-app/requirements.txt @@ -0,0 +1,10 @@ +fastapi>=0.104.0 +uvicorn[standard]>=0.24.0 +python-multipart>=0.0.6 +fonttools[woff]>=4.47.0 +Pillow>=10.0.0 +google-generativeai>=0.3.0 +openai>=1.6.0 +aiofiles>=23.2.0 +jinja2>=3.1.0 +numpy>=1.24.0