import tempfile import zipfile from pathlib import Path import gradio as gr import spaces REPLACEMENTS1 = { '': 'и', '': '҃', '': '҃', '': 'ч', '': 'Ѥ', '': 'н', '': '҇', '': '҃', '': '~', '': 'ⷦ҇', '': ' ⷮ', '': '҆̀', '': '҆̀', '': 'ⷹ', '': 'ч', '': 'ⷹ', '': ':', '': 'Чⷹ', '': 'о', '': 'с', '': 'е', '': '͠', '': '·̀', '': '·̀', 'ⷭⷭ': '҇', '': '҇', '': '҆', '': 'ⷩ', '': 'ꙶ', '': 'оу', '': 'ꙁ', '': 'ⷿ', '': 'ⷿ', '': 'ѧ', ' ': 'с', '': 'ѱ', '': 'Ѥ', '': 'р҃', '꙳': 'у', '꙯': '҃', '': '͡', '': '͠', 'ъ': 'уъ', '': 'у', 'ⷣ': 'ⷣ͡', 'ⷮ': 'ⷣ͡', 'ⷯ': '̈͠', '': 'ꙩ́', 'ѧ': 'уѧ', '': '҆', '': 'ꙺ', '': 'Ю', '': 'ꙻ', 'ⷤ': '', } REPLACEMENTS2 = { '0': '́', '1': '́', '2': '̀', '3': '҆', '4': '҆́', '5': '҆̀', '6': '̑', # 'б': 'Б', '7': '҃', '8': 'ⸯ', '9': 'Җ҃', '™': '҃' } REPLACEMENTS3 = { '': ' ', '': 'А', '': 'Б', '': 'В', '': 'Г', '': 'Д', '': 'Е', '': 'Ꙁ', '': 'И', '': 'І', '': 'К', '': 'Л', '': 'М', '': 'Н', '': 'О', '': 'П', '': 'Р', '': 'С', '': 'Т', '': 'ОУ', '': 'Х', '': 'Ч', '': 'Ъ', '': 'Ѣ', '': 'ꙗ', '': 'а', '': 'б', '': 'в', '': 'г', '': 'д', '': 'е', '': 'ж', '': 'ꙅ', '': 'ꙁ', '': 'и', '': 'І', '': 'к', '': 'л', '': 'м', '': 'н', '': 'о', '': 'п', '': 'р', '': 'с', '': 'т', '': 'оу', '': 'оу', '': 'ф', '': 'х', '': 'ѡ', '': 'ѡт', '': 'ц', '': 'ч', '': 'ш', '': 'шт', '': 'щ', '': 'ъ', '': 'ꙑ', '': 'ы', '': 'ь', '': 'ѣ', '': 'ю', '': 'ꙗ', '': 'ѥ', '': 'ѧ', '': 'ѩ', '': 'ѫ', '': 'ѭ', '': 'ѳ', '': 'у', '': 'ⷡ', '': 'ⷣ', '': 'ⷷ', '': 'ⷩ', '': 'ⷪ', '': 'ⷭ', '': 'ⷰ', '': 'ⷽ', '': 'ʾ', '': '̑', '': '̾', '': '҃', '': '꙯', '': '̀', '': '̅', '': '҇', '': '҇', '': '҃', '': '·', '': '⁘', '': '⁖', '': ':·', '': '⁘', '': '҃', '': '⊂', } REPLACEMENTS4 = { '1': '̋', '2': '̀', '3': '҆', '7': '҃', '9': 'ж҃', '0': '́о', 'l': 'л҃', '†': 'а̑', '‡': 'І̑', '‰': 'Ѧ̑', '‹': 'І҃', '№': 'л҃', 'D': 'д ⷭ҇', '#': '̑', '¦': 'х҃', '¤': '҂', '™': 'т҃', '§': 'Ч҃', } REPLACEMENTS5 = { 'A': 'а̀', 'B': 'ѣ̑', 'C': 'ⷭ҇', 'D': 'дⷭ҇', 'E': 'ѐ', 'F': 'ѳ', 'G': 'г҃', 'H': 'ѡ́', 'I': 'І', 'J': 'і̀', 'K': 'Ꙗ҆', 'L': 'лⷣ', 'M': 'Ѷ', 'N': 'Ѻ҆', 'O': 'Ѻ', 'P': 'Ѱ', 'Q': 'Ѽ҃', 'R': 'р҃', 'S': 'ѧ̀', 'T': 'Ѿ', 'U': 'Оу', 'V': 'Ѵ', 'W': 'Ѡ', 'X': 'Ѯ', 'Y': 'ꙋ̀', 'Z': 'Ѧ', 'a': 'а́', 'b': 'ⷪ҇', 'c': 'ⷭ҇', 'd': 'ⷣ', 'e': 'е́', 'f': 'ѳ', 'g': 'ⷢ҇', 'h': 'ы́', 'i': 'і', 'j': 'і́', 'k': 'ꙗ҆', 'l': 'л҃', 'm': 'ѷ', 'n': 'ѻ҆', 'o': 'ѻ', 'p': 'ѱ', 'q': 'ѽ', 'r': 'рⷭ҇', 's': 'ѧ́', 't': 'ѿ', 'u': 'Оу', 'v': 'ѵ', 'w': 'ѡ', 'x': 'ѯ', 'y': 'ꙋ́', 'z': 'ѧ', '±': 'Ꙗ҆̀', '}': 'и҃', '4': '҆́', '5': '҆̀', '6': '̑', '8': '̾', '1': '́', '2': '̀', '3': '̑', '7': '҃', '9': 'ж҃', '0': 'о́', '™': 'т҃', '†': 'а̑', '‡': 'І̑', '‰': 'Ѧ̑', '‹': 'І҃', '№': 'л҃', '#': '̑', '¦': '҃х', '¤': '҂', '§': 'Ч҃', } DATABASE_MAPPINGS = { "Cyrillomethodiana": REPLACEMENTS1, "Азбука веры": REPLACEMENTS2, "Manuscripts.ru": REPLACEMENTS3, "Православие и мир": REPLACEMENTS4, "Минея": REPLACEMENTS5, } FONT_DIR = Path("fonts") SUPPORTED_FONT_EXTENSIONS = {".otf", ".ttf", ".woff", ".woff2"} if FONT_DIR.exists(): gr.set_static_paths(paths=[FONT_DIR]) def discover_fonts(): """Return {display_name: CSS font-family} for local font files.""" fonts = {"Default Unicode": "Default Unicode"} if not FONT_DIR.exists(): return fonts for font_path in sorted(FONT_DIR.iterdir()): if ( font_path.is_file() and font_path.suffix.lower() in SUPPORTED_FONT_EXTENSIONS ): display_name = font_path.stem.replace("_", " ") fonts[display_name] = font_path.stem return fonts AVAILABLE_FONTS = discover_fonts() def font_format(extension): formats = { ".otf": "opentype", ".ttf": "truetype", ".woff": "woff", ".woff2": "woff2", } return formats[extension.lower()] def build_font_face_css(): rules = [] if not FONT_DIR.exists(): return "" for font_path in sorted(FONT_DIR.iterdir()): if ( not font_path.is_file() or font_path.suffix.lower() not in SUPPORTED_FONT_EXTENSIONS ): continue family = font_path.stem file_format = font_format(font_path.suffix) rules.append( f""" @font-face {{ font-family: "{family}"; src: url("/gradio_api/file=fonts/{font_path.name}") format("{file_format}"); font-style: normal; font-weight: normal; font-display: swap; }} """ ) return "\n".join(rules) def build_css(): font_faces = build_font_face_css() return f""" {font_faces} body {{ background: linear-gradient( 135deg, #eaf3ff 0%, #ffffff 48%, #dbeafe 100% ); }} .gradio-container {{ max-width: 1000px !important; width: 80% !important; margin: auto !important; font-family: Arial, Helvetica, sans-serif !important; }} #main-card {{ background: #ffffff; border: 1px solid #bfdbfe; border-radius: 26px; padding: 30px; box-shadow: 0 20px 50px rgba(15, 23, 42, 0.16); }} #title {{ text-align: center; color: #020617; font-size: 2.4rem; font-weight: 900; margin-bottom: 0.25rem; }} #subtitle {{ text-align: center; color: #1e40af; font-size: 1.05rem; line-height: 1.55; margin-bottom: 1.6rem; }} textarea, input, select {{ border-radius: 16px !important; border: 1.5px solid #2563eb !important; background: #ffffff !important; color: #020617 !important; }} label {{ color: #020617 !important; font-weight: 800 !important; }} button {{ background: linear-gradient( 90deg, #020617, #1d4ed8 ) !important; color: #ffffff !important; border: none !important; border-radius: 16px !important; padding: 13px 24px !important; font-weight: 900 !important; }} #output-box textarea {{ background: #f8fbff !important; font-family: "Noto Sans", "DejaVu Sans", Arial, sans-serif !important; font-size: 25px !important; line-height: 1.8 !important; }} /* Hide Gradio footer: Use via API, Built with Gradio, Settings */ footer, .gradio-container > footer, .api, .settings, [class*="footer"], a[href*="gradio.app"], button[aria-label="Settings"], button[aria-label="Use via API"], button[aria-label*="API"], button[aria-label*="Settings"] {{ display: none !important; }} """ CUSTOM_CSS = build_css() FONT_CHANGE_JS = r""" (fontName) => { const fallback = '"Noto Sans", "DejaVu Sans", Arial, sans-serif'; const selectedFont = fontName && fontName !== "Default Unicode" ? `"${fontName}", ${fallback}` : fallback; const applyFont = () => { const outputBox = document.querySelector( "#output-box textarea" ); if (outputBox) { outputBox.style.setProperty( "font-family", selectedFont, "important" ); outputBox.style.setProperty( "font-size", "25px", "important" ); outputBox.style.setProperty( "line-height", "1.8", "important" ); } }; applyFont(); setTimeout(applyFont, 100); setTimeout(applyFont, 400); return fontName; } """ def apply_mapping(text, mapping): ordered_mapping = sorted( mapping.items(), key=lambda item: len(item[0]), reverse=True, ) for old_char, new_char in ordered_mapping: text = text.replace(old_char, new_char) return text @spaces.GPU(duration=60) def convert_text(input_text, database_name): input_text = str(input_text) if not input_text.strip(): return "", "Please paste text to process." mapping = DATABASE_MAPPINGS.get(database_name) if mapping is None: return "", "Please select a valid database." converted_text = apply_mapping(input_text, mapping) return converted_text, f"Converted using {database_name}." def clear_text(): return "", "", "" @spaces.GPU(duration=60) def convert_txt_files(uploaded_files, database_name): if not uploaded_files: return None, "Please upload at least one TXT file." mapping = DATABASE_MAPPINGS.get(database_name) if mapping is None: return None, "Please select a valid database." temp_directory = Path( tempfile.mkdtemp(prefix="ocs_converter_") ) output_directory = temp_directory / "converted" output_directory.mkdir(parents=True, exist_ok=True) converted_count = 0 skipped_files = [] for uploaded_file in uploaded_files: input_path = Path(uploaded_file) if input_path.suffix.lower() != ".txt": skipped_files.append(input_path.name) continue try: input_text = input_path.read_text(encoding="utf-8") except UnicodeDecodeError: skipped_files.append(input_path.name) continue converted_text = apply_mapping( input_text, mapping, ) output_path = output_directory / input_path.name output_path.write_text( converted_text, encoding="utf-8", ) converted_count += 1 if converted_count == 0: return None, "No UTF-8 TXT files could be processed." zip_path = temp_directory / "converted_text_files.zip" with zipfile.ZipFile( zip_path, mode="w", compression=zipfile.ZIP_DEFLATED, ) as archive: for output_path in output_directory.glob("*.txt"): archive.write( output_path, arcname=output_path.name, ) message = ( f"Converted {converted_count} file(s) " f"using {database_name}." ) if skipped_files: message += " Skipped: " + ", ".join(skipped_files) return str(zip_path), message with gr.Blocks( title="Converter for Old Church Slavonic Demo", ) as demo: with gr.Column(elem_id="main-card"): gr.Markdown( "# Converter for Old Church Slavonic Demo", elem_id="title", ) gr.Markdown( "Select a database and convert " "Old Church Slavonic text.", elem_id="subtitle", ) database_input = gr.Dropdown( label="Database", choices=list(DATABASE_MAPPINGS.keys()), value="Cyrillomethodiana", interactive=True, ) font_input = gr.Dropdown( label="Output font", choices=list(AVAILABLE_FONTS.keys()), value="Default Unicode", interactive=True, info=( " " ), ) font_state = gr.Textbox( value="Default Unicode", visible=False, ) with gr.Tab("Text conversion"): input_text = gr.Textbox( label="Input text", lines=7, placeholder="Paste text here...", ) with gr.Row(): convert_button = gr.Button( "Convert text", variant="primary", ) clear_button = gr.Button("Clear") output_text = gr.Textbox( label="Converted text", lines=8, interactive=False, elem_id="output-box", ) text_status = gr.Textbox( label="Status", lines=1, interactive=False, ) with gr.Tab("TXT file conversion"): file_input = gr.File( label="Upload UTF-8 TXT files", file_types=[".txt"], file_count="multiple", type="filepath", ) file_convert_button = gr.Button( "Convert TXT files", variant="primary", ) file_output = gr.File( label="Download converted ZIP", interactive=False, ) file_status = gr.Textbox( label="Status", lines=3, interactive=False, ) font_input.change( fn=None, inputs=[font_input], outputs=[font_state], js=FONT_CHANGE_JS, ) convert_button.click( fn=convert_text, inputs=[ input_text, database_input, ], outputs=[ output_text, text_status, ], ).then( fn=None, inputs=[font_input], outputs=[font_state], js=FONT_CHANGE_JS, ) input_text.submit( fn=convert_text, inputs=[ input_text, database_input, ], outputs=[ output_text, text_status, ], ).then( fn=None, inputs=[font_input], outputs=[font_state], js=FONT_CHANGE_JS, ) clear_button.click( fn=clear_text, inputs=[], outputs=[ input_text, output_text, text_status, ], ) file_convert_button.click( fn=convert_txt_files, inputs=[ file_input, database_input, ], outputs=[ file_output, file_status, ], ) demo.load( fn=None, inputs=[font_input], outputs=[font_state], js=FONT_CHANGE_JS, ) demo.queue() demo.launch( server_name="0.0.0.0", server_port=7860, ssr_mode=False, theme=gr.themes.Soft( primary_hue="blue", secondary_hue="sky", neutral_hue="slate", ), css=CUSTOM_CSS, )