package main import ( "bytes" "fmt" "math" "os" "path/filepath" "regexp" "strings" "time" ) // === PDF генератор === // // Два режима: // 1. TTF mode: найден системный шрифт с кириллицей → встраиваем subset, // текст кодируем в UTF-16BE, используем Type0/CIDFont. // 2. Fallback mode: шрифт не найден → встроенные PDF шрифты (Helvetica), // кириллица транслитерируется. // A4 в pt const ( pageWidth = 595.28 pageHeight = 841.89 marginL = 60.0 marginR = 60.0 marginT = 60.0 marginB = 60.0 textWidth = pageWidth - marginL - marginR ) // Размеры шрифтов const ( fontSizeBody = 11.0 fontSizeH1 = 22.0 fontSizeH2 = 17.0 fontSizeH3 = 14.0 fontSizeH4 = 12.0 fontSizeCode = 9.5 lineHeightMul = 1.4 ) // pdfColor — RGB 0.0–1.0 type pdfColor struct{ r, g, b float64 } var ( colorBlack = pdfColor{0, 0, 0} colorDarkGray = pdfColor{0.2, 0.2, 0.2} colorGray = pdfColor{0.5, 0.5, 0.5} colorCodeBg = pdfColor{0.95, 0.95, 0.95} colorH1Color = pdfColor{0.1, 0.1, 0.4} colorH2Color = pdfColor{0.15, 0.15, 0.5} colorH3Color = pdfColor{0.2, 0.2, 0.55} colorLinkColor = pdfColor{0.1, 0.1, 0.8} colorAccent = pdfColor{0.3, 0.3, 0.7} ) // pdfFontStyle — стиль шрифта type pdfFontStyle int const ( styleRegular pdfFontStyle = iota styleBold styleItalic styleBoldItalic styleMono styleMonoBold ) // pdfFont — логический шрифт type pdfFont struct { style pdfFontStyle size float64 } var ( fontBody = pdfFont{styleRegular, fontSizeBody} fontBold = pdfFont{styleBold, fontSizeBody} fontItalic = pdfFont{styleItalic, fontSizeBody} fontBoldItal = pdfFont{styleBoldItalic, fontSizeBody} fontMono = pdfFont{styleMono, fontSizeCode} fontH1 = pdfFont{styleBold, fontSizeH1} fontH2 = pdfFont{styleBold, fontSizeH2} fontH3 = pdfFont{styleBold, fontSizeH3} fontH4 = pdfFont{styleBold, fontSizeH4} ) // pdfSpan — фрагмент текста type pdfSpan struct { text string font pdfFont color pdfColor isLink bool linkURL string } // pdfBlock — блок контента type pdfBlock struct { kind string spans []pdfSpan rawText string indent int bullet string } // === Движок рендеринга === // pdfEngine — абстракция над двумя режимами вывода текста type pdfEngine interface { // textCmd возвращает PDF команду для вывода текста textCmd(text string, font pdfFont, color pdfColor, x, y float64) string // textWidth возвращает ширину строки в pt textWidth(text string, font pdfFont) float64 // fontResources возвращает словарь /Font для страницы fontResources() string // writeObjects записывает объекты шрифтов в документ writeObjects(doc *pdfDoc) } // === Fallback engine (встроенные PDF шрифты) === type fallbackEngine struct{} func (e *fallbackEngine) textCmd(text string, font pdfFont, color pdfColor, x, y float64) string { return fmt.Sprintf( "BT /%s %.2f Tf %.3f %.3f %.3f rg %.2f %.2f Td (%s) Tj ET\n", e.fontRef(font), font.size, color.r, color.g, color.b, x, y, pdfEscapeLatin(text), ) } func (e *fallbackEngine) textWidth(text string, font pdfFont) float64 { ratio := 0.55 if font.style == styleMono || font.style == styleMonoBold { ratio = 0.6 } return float64(len([]rune(text))) * font.size * ratio } func (e *fallbackEngine) fontResources() string { return "/Font << /F1 3 0 R /F2 4 0 R /F3 5 0 R /F4 6 0 R /F5 7 0 R /F6 8 0 R >>" } func (e *fallbackEngine) writeObjects(doc *pdfDoc) { builtins := []string{ "Helvetica", "Helvetica-Bold", "Helvetica-Oblique", "Helvetica-BoldOblique", "Courier", "Courier-Bold", } for _, name := range builtins { doc.newObject() doc.write(fmt.Sprintf( "<< /Type /Font /Subtype /Type1 /BaseFont /%s /Encoding /WinAnsiEncoding >>\nendobj\n\n", name, )) } } func (e *fallbackEngine) fontRef(font pdfFont) string { switch font.style { case styleBold: return "F2" case styleItalic: return "F3" case styleBoldItalic: return "F4" case styleMono: return "F5" case styleMonoBold: return "F6" default: return "F1" } } // === TTF engine (системный шрифт с кириллицей) === type ttfEngine struct { regular *EmbeddedFont bold *EmbeddedFont italic *EmbeddedFont mono *EmbeddedFont // Courier — встроенный, без кириллицы fontPath string // ID объектов после записи regularRef string boldRef string italicRef string } func newTTFEngine(fontPath string) (*ttfEngine, error) { ttf, err := LoadTTF(fontPath) if err != nil { return nil, err } if !ttf.HasCyrillic() { return nil, fmt.Errorf("font has no cyrillic glyphs") } return &ttfEngine{ regular: NewEmbeddedFont(ttf), bold: NewEmbeddedFont(ttf), // используем тот же шрифт для bold (упрощение) italic: NewEmbeddedFont(ttf), fontPath: fontPath, }, nil } func (e *ttfEngine) embeddedFor(font pdfFont) *EmbeddedFont { switch font.style { case styleBold, styleBoldItalic: return e.bold case styleItalic: return e.italic default: return e.regular } } func (e *ttfEngine) textCmd(text string, font pdfFont, color pdfColor, x, y float64) string { ef := e.embeddedFor(font) // Кодируем в UTF-16BE и регистрируем глифы encoded := encodeUTF16BEHex(text, ef) fontRef := e.fontRefFor(font) return fmt.Sprintf( "BT /%s %.2f Tf %.3f %.3f %.3f rg %.2f %.2f Td <%s> Tj ET\n", fontRef, font.size, color.r, color.g, color.b, x, y, encoded, ) } func (e *ttfEngine) fontRefFor(font pdfFont) string { switch font.style { case styleBold, styleBoldItalic: return "FB" case styleItalic: return "FI" case styleMono, styleMonoBold: return "FM" default: return "FR" } } func (e *ttfEngine) textWidth(text string, font pdfFont) float64 { if font.style == styleMono || font.style == styleMonoBold { return float64(len([]rune(text))) * font.size * 0.6 } ef := e.embeddedFor(font) total := 0.0 for _, r := range text { w := ef.GlyphWidth(r) total += float64(w) * font.size / 1000.0 } return total } func (e *ttfEngine) fontResources() string { return fmt.Sprintf( "/Font << /FR %s /FB %s /FI %s /FM 3 0 R >>", e.regularRef, e.boldRef, e.italicRef, ) } func (e *ttfEngine) writeObjects(doc *pdfDoc) { // Courier для кода (встроенный) doc.newObject() doc.write("<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>\nendobj\n\n") // Если bold и italic указывают на тот же TTF — можно встроить один раз. // Для упрощения встраиваем все три отдельно (subset будет разный по использованным глифам). e.regularRef = e.regular.WriteToPDF(doc, "GrimoirRegular") e.boldRef = e.bold.WriteToPDF(doc, "GrimoirBold") e.italicRef = e.italic.WriteToPDF(doc, "GrimoirItalic") } // encodeUTF16BEHex кодирует строку в UTF-16BE hex для PDF и регистрирует глифы func encodeUTF16BEHex(text string, ef *EmbeddedFont) string { var sb strings.Builder for _, r := range text { gid := ef.UseRune(r) sb.WriteString(fmt.Sprintf("%04X", gid)) } return sb.String() } // === Основной документ === type pdfDoc struct { buf bytes.Buffer objects []int pageIDs []int engine pdfEngine curPage int curY float64 pageBuf bytes.Buffer } // ExportToPDF экспортирует markdown в PDF func ExportToPDF(lines []string, outputPath string) error { // Пробуем найти системный шрифт var engine pdfEngine fontPath := FindCyrillicFont() if fontPath != "" { ttfe, err := newTTFEngine(fontPath) if err == nil { engine = ttfe } } if engine == nil { engine = &fallbackEngine{} } blocks := parseMarkdownToBlocks(lines) doc := &pdfDoc{engine: engine} doc.writePDFHeader() doc.addPage() for _, block := range blocks { doc.renderBlock(block) } doc.finalizePage() doc.writeCrossRef() return os.WriteFile(outputPath, doc.buf.Bytes(), 0644) } // === Парсинг Markdown → блоки === func parseMarkdownToBlocks(lines []string) []pdfBlock { var blocks []pdfBlock inCode := false codeLang := "" var codeLines []string for _, line := range lines { if strings.HasPrefix(line, "```") { if inCode { blocks = append(blocks, pdfBlock{kind: "code", rawText: strings.Join(codeLines, "\n")}) inCode = false codeLines = nil codeLang = "" } else { inCode = true codeLang = strings.TrimSpace(strings.TrimPrefix(line, "```")) _ = codeLang } continue } if inCode { codeLines = append(codeLines, line) continue } if regexp.MustCompile(`^(-{3,}|_{3,}|\*{3,})\s*$`).MatchString(line) { blocks = append(blocks, pdfBlock{kind: "hr"}) continue } if m := regexp.MustCompile(`^(#{1,4})\s+(.+)`).FindStringSubmatch(line); m != nil { level := len(m[1]) blocks = append(blocks, pdfBlock{ kind: fmt.Sprintf("h%d", level), spans: parseInlineMarkdown(m[2], fontForHeading(level), colorForHeading(level)), }) continue } if strings.HasPrefix(line, "> ") { blocks = append(blocks, pdfBlock{ kind: "quote", spans: parseInlineMarkdown(strings.TrimPrefix(line, "> "), fontItalic, colorDarkGray), }) continue } if m := regexp.MustCompile(`^(\s*)([-*+])\s+(.+)`).FindStringSubmatch(line); m != nil { blocks = append(blocks, pdfBlock{ kind: "listitem", bullet: "•", indent: len(m[1]) / 2, spans: parseInlineMarkdown(m[3], fontBody, colorBlack), }) continue } if m := regexp.MustCompile(`^(\s*)(\d+)\.\s+(.+)`).FindStringSubmatch(line); m != nil { blocks = append(blocks, pdfBlock{ kind: "listitem", bullet: m[2] + ".", indent: len(m[1]) / 2, spans: parseInlineMarkdown(m[3], fontBody, colorBlack), }) continue } if strings.TrimSpace(line) == "" { blocks = append(blocks, pdfBlock{kind: "space"}) continue } if strings.Contains(line, "|") && strings.HasPrefix(strings.TrimSpace(line), "|") { if regexp.MustCompile(`^\|[\s\-:|]+\|`).MatchString(strings.TrimSpace(line)) { blocks = append(blocks, pdfBlock{kind: "hr"}) continue } cells := strings.Split(strings.Trim(strings.TrimSpace(line), "|"), "|") var spans []pdfSpan for i, c := range cells { if i > 0 { spans = append(spans, pdfSpan{text: " | ", font: fontBody, color: colorGray}) } spans = append(spans, parseInlineMarkdown(strings.TrimSpace(c), fontBody, colorBlack)...) } blocks = append(blocks, pdfBlock{kind: "para", spans: spans}) continue } blocks = append(blocks, pdfBlock{ kind: "para", spans: parseInlineMarkdown(line, fontBody, colorBlack), }) } return blocks } func parseInlineMarkdown(text string, defaultFont pdfFont, defaultColor pdfColor) []pdfSpan { type fragment struct { text string bold, italic, isCode, isLink bool linkURL string start, end int } patterns := []struct { re *regexp.Regexp kind string }{ {regexp.MustCompile(`\*\*([^*]+)\*\*`), "bold"}, {regexp.MustCompile(`\*([^*]+)\*`), "italic"}, {regexp.MustCompile("`([^`]+)`"), "code"}, {regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`), "link"}, } frags := []fragment{} used := make([]bool, len(text)) for _, p := range patterns { for _, m := range p.re.FindAllStringSubmatchIndex(text, -1) { overlap := false for i := m[0]; i < m[1] && i < len(used); i++ { if used[i] { overlap = true break } } if overlap { continue } for i := m[0]; i < m[1] && i < len(used); i++ { used[i] = true } f := fragment{start: m[0], end: m[1]} switch p.kind { case "bold": f.text = text[m[2]:m[3]] f.bold = true case "italic": f.text = text[m[2]:m[3]] f.italic = true case "code": f.text = text[m[2]:m[3]] f.isCode = true case "link": f.text = text[m[2]:m[3]] f.isLink = true if len(m) > 5 && m[4] >= 0 { f.linkURL = text[m[4]:m[5]] } } frags = append(frags, f) } } // Сортировка по позиции for i := range frags { for j := i + 1; j < len(frags); j++ { if frags[j].start < frags[i].start { frags[i], frags[j] = frags[j], frags[i] } } } var spans []pdfSpan pos := 0 for _, f := range frags { if f.start > pos { spans = append(spans, pdfSpan{text: text[pos:f.start], font: defaultFont, color: defaultColor}) } var font pdfFont var color pdfColor switch { case f.isCode: font = fontMono color = pdfColor{0.1, 0.1, 0.5} case f.isLink: font = defaultFont color = colorLinkColor case f.bold && f.italic: font = fontBoldItal color = defaultColor case f.bold: font = fontBold color = defaultColor case f.italic: font = fontItalic color = defaultColor default: font = defaultFont color = defaultColor } spans = append(spans, pdfSpan{text: f.text, font: font, color: color, isLink: f.isLink, linkURL: f.linkURL}) pos = f.end } if pos < len(text) && pos >= 0 { spans = append(spans, pdfSpan{text: text[pos:], font: defaultFont, color: defaultColor}) } if len(spans) == 0 { spans = []pdfSpan{{text: text, font: defaultFont, color: defaultColor}} } return spans } func fontForHeading(level int) pdfFont { switch level { case 1: return fontH1 case 2: return fontH2 case 3: return fontH3 default: return fontH4 } } func colorForHeading(level int) pdfColor { switch level { case 1: return colorH1Color case 2: return colorH2Color case 3: return colorH3Color default: return colorDarkGray } } // === Генерация PDF === func (doc *pdfDoc) writePDFHeader() { doc.write("%PDF-1.4\n") doc.write("%\xe2\xe3\xcf\xd3\n") } func (doc *pdfDoc) addPage() { doc.curPage++ doc.curY = marginT doc.pageBuf.Reset() } func (doc *pdfDoc) finalizePage() { if doc.pageBuf.Len() == 0 && doc.curPage == 0 { return } stream := doc.pageBuf.String() contentID := doc.newObject() doc.write(fmt.Sprintf("<< /Length %d >>\nstream\n", len(stream))) doc.write(stream) doc.write("\nendstream\nendobj\n\n") pageID := doc.newObject() doc.write(fmt.Sprintf( "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %.2f %.2f]\n"+ " /Contents %d 0 R\n"+ " /Resources << %s >>\n"+ ">>\nendobj\n\n", pageWidth, pageHeight, contentID, doc.engine.fontResources(), )) doc.pageIDs = append(doc.pageIDs, pageID) } func (doc *pdfDoc) renderBlock(block pdfBlock) { switch block.kind { case "space": doc.advanceY(fontSizeBody * 0.5) case "hr": doc.advanceY(8) doc.checkPageBreak(20) y := pageHeight - doc.curY doc.pageBuf.WriteString(fmt.Sprintf( "q %.3f %.3f %.3f RG 0.5 w %.2f %.2f m %.2f %.2f l S Q\n", colorGray.r, colorGray.g, colorGray.b, marginL, y, pageWidth-marginR, y, )) doc.advanceY(8) case "h1": doc.advanceY(fontSizeH1 * 0.6) doc.checkPageBreak(fontSizeH1 * 2.5) doc.renderHeading(block, fontH1, colorH1Color) y := pageHeight - doc.curY + 2 doc.pageBuf.WriteString(fmt.Sprintf( "q %.3f %.3f %.3f RG 1 w %.2f %.2f m %.2f %.2f l S Q\n", colorAccent.r, colorAccent.g, colorAccent.b, marginL, y, pageWidth-marginR, y, )) doc.advanceY(6) case "h2": doc.advanceY(fontSizeH2 * 0.5) doc.checkPageBreak(fontSizeH2 * 2.5) doc.renderHeading(block, fontH2, colorH2Color) doc.advanceY(4) case "h3": doc.advanceY(fontSizeH3 * 0.4) doc.checkPageBreak(fontSizeH3 * 2) doc.renderHeading(block, fontH3, colorH3Color) doc.advanceY(3) case "h4": doc.advanceY(fontSizeH4 * 0.3) doc.checkPageBreak(fontSizeH4 * 2) doc.renderHeading(block, fontH4, colorDarkGray) doc.advanceY(2) case "para": doc.checkPageBreak(fontSizeBody * lineHeightMul * 2) doc.renderSpans(block.spans, marginL, fontSizeBody*lineHeightMul) doc.advanceY(fontSizeBody * 0.3) case "quote": doc.advanceY(4) doc.checkPageBreak(fontSizeBody * lineHeightMul * 2) startY := doc.curY doc.renderSpans(block.spans, marginL+16, fontSizeBody*lineHeightMul) endY := doc.curY barH := endY - startY + fontSizeBody barY := pageHeight - startY doc.pageBuf.WriteString(fmt.Sprintf( "q %.3f %.3f %.3f rg %.2f %.2f %.2f %.2f re f Q\n", colorAccent.r, colorAccent.g, colorAccent.b, marginL, barY-barH, 3.0, barH, )) doc.pageBuf.WriteString(fmt.Sprintf( "q 0.96 0.96 0.98 rg %.2f %.2f %.2f %.2f re f Q\n", marginL+4, barY-barH, textWidth-4, barH, )) doc.advanceY(4) case "listitem": doc.checkPageBreak(fontSizeBody * lineHeightMul * 1.5) bulletX := marginL + float64(block.indent)*20 textX := bulletX + 18 bulletY := pageHeight - doc.curY - fontSizeBody doc.pageBuf.WriteString(doc.engine.textCmd( block.bullet, fontBody, colorAccent, bulletX, bulletY, )) doc.renderSpans(block.spans, textX, fontSizeBody*lineHeightMul) doc.advanceY(2) case "code": doc.renderCodeBlock(block.rawText) } } func (doc *pdfDoc) renderHeading(block pdfBlock, font pdfFont, color pdfColor) { var sb strings.Builder for _, s := range block.spans { sb.WriteString(s.text) } y := pageHeight - doc.curY - font.size doc.pageBuf.WriteString(doc.engine.textCmd(sb.String(), font, color, marginL, y)) doc.advanceY(font.size * lineHeightMul) } func (doc *pdfDoc) renderSpans(spans []pdfSpan, leftMargin, lineHeight float64) { maxW := pageWidth - marginR - (leftMargin - marginL) wrappedLines := wrapSpans(spans, maxW, doc.engine) for _, line := range wrappedLines { doc.checkPageBreak(lineHeight) y := pageHeight - doc.curY - fontSizeBody x := leftMargin for _, span := range line { if span.text == "" { continue } doc.pageBuf.WriteString(doc.engine.textCmd(span.text, span.font, span.color, x, y)) x += doc.engine.textWidth(span.text, span.font) } doc.advanceY(lineHeight) } } func (doc *pdfDoc) renderCodeBlock(code string) { lines := strings.Split(code, "\n") lineH := fontSizeCode * lineHeightMul totalH := float64(len(lines))*lineH + 16 doc.advanceY(6) doc.checkPageBreak(math.Min(totalH, pageHeight-marginT-marginB)) bgY := pageHeight - doc.curY bgH := float64(len(lines))*lineH + 12 doc.pageBuf.WriteString(fmt.Sprintf( "q %.3f %.3f %.3f rg %.2f %.2f %.2f %.2f re f Q\n", colorCodeBg.r, colorCodeBg.g, colorCodeBg.b, marginL, bgY-bgH, textWidth, bgH, )) doc.pageBuf.WriteString(fmt.Sprintf( "q %.3f %.3f %.3f rg %.2f %.2f %.2f %.2f re f Q\n", colorAccent.r, colorAccent.g, colorAccent.b, marginL, bgY-bgH, 3.0, bgH, )) doc.advanceY(8) for _, line := range lines { doc.checkPageBreak(lineH) y := pageHeight - doc.curY - fontSizeCode doc.pageBuf.WriteString(doc.engine.textCmd(line, fontMono, colorDarkGray, marginL+8, y)) doc.advanceY(lineH) } doc.advanceY(6) } // wrapSpans разбивает spans на строки по maxWidth func wrapSpans(spans []pdfSpan, maxWidth float64, eng pdfEngine) [][]pdfSpan { var result [][]pdfSpan var curLine []pdfSpan curW := 0.0 for _, span := range spans { words := strings.Fields(span.text) if len(words) == 0 { curLine = append(curLine, span) continue } for i, word := range words { ww := eng.textWidth(word, span.font) sw := eng.textWidth(" ", span.font) needSpace := i > 0 || (len(curLine) > 0 && curW > 0) addW := ww if needSpace { addW += sw } if curW+addW > maxWidth && curW > 0 { result = append(result, curLine) curLine = nil curW = 0 needSpace = false addW = ww } txt := word if needSpace { txt = " " + word } curLine = append(curLine, pdfSpan{text: txt, font: span.font, color: span.color}) curW += addW } } if len(curLine) > 0 { result = append(result, curLine) } if len(result) == 0 { result = [][]pdfSpan{{}} } return result } // === Финализация документа === func (doc *pdfDoc) writeCrossRef() { // Catalog doc.newObject() doc.write("<< /Type /Catalog /Pages 2 0 R >>\nendobj\n\n") // Pages var pageRefs strings.Builder for _, id := range doc.pageIDs { pageRefs.WriteString(fmt.Sprintf("%d 0 R ", id)) } doc.newObject() doc.write(fmt.Sprintf( "<< /Type /Pages /Kids [%s] /Count %d >>\nendobj\n\n", pageRefs.String(), len(doc.pageIDs), )) // Объекты шрифтов doc.engine.writeObjects(doc) // Info now := time.Now().Format("20060102150405") doc.newObject() doc.write(fmt.Sprintf( "<< /Creator (grimoir) /CreationDate (D:%s) >>\nendobj\n\n", now, )) // xref xrefOff := doc.buf.Len() total := len(doc.objects) + 1 doc.write(fmt.Sprintf("xref\n0 %d\n", total)) doc.write("0000000000 65535 f \n") for _, off := range doc.objects { doc.write(fmt.Sprintf("%010d 00000 n \n", off)) } doc.write(fmt.Sprintf( "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", total, xrefOff, )) } // === Утилиты === func (doc *pdfDoc) write(s string) { doc.buf.WriteString(s) } func (doc *pdfDoc) newObject() int { id := len(doc.objects) + 1 doc.objects = append(doc.objects, doc.buf.Len()) doc.write(fmt.Sprintf("%d 0 obj\n", id)) return id } func (doc *pdfDoc) advanceY(d float64) { doc.curY += d } func (doc *pdfDoc) checkPageBreak(needed float64) { if doc.curY+needed > pageHeight-marginB { doc.finalizePage() doc.addPage() } } // pdfEscapeLatin экранирует строку для Latin-1 PDF (fallback режим) func pdfEscapeLatin(s string) string { var b strings.Builder for _, r := range s { switch { case r == '(': b.WriteString(`\(`) case r == ')': b.WriteString(`\)`) case r == '\\': b.WriteString(`\\`) case r >= 0x20 && r <= 0x7E: b.WriteRune(r) default: b.WriteString(translitRune(r)) } } return b.String() } // translitRune транслитерирует символ (fallback) func translitRune(r rune) string { m := map[rune]string{ 'а': "a", 'б': "b", 'в': "v", 'г': "g", 'д': "d", 'е': "e", 'ё': "yo", 'ж': "zh", 'з': "z", 'и': "i", 'й': "j", 'к': "k", 'л': "l", 'м': "m", 'н': "n", 'о': "o", 'п': "p", 'р': "r", 'с': "s", 'т': "t", 'у': "u", 'ф': "f", 'х': "kh", 'ц': "ts", 'ч': "ch", 'ш': "sh", 'щ': "shch", 'ъ': "", 'ы': "y", 'ь': "", 'э': "e", 'ю': "yu", 'я': "ya", 'А': "A", 'Б': "B", 'В': "V", 'Г': "G", 'Д': "D", 'Е': "E", 'Ё': "Yo", 'Ж': "Zh", 'З': "Z", 'И': "I", 'Й': "J", 'К': "K", 'Л': "L", 'М': "M", 'Н': "N", 'О': "O", 'П': "P", 'Р': "R", 'С': "S", 'Т': "T", 'У': "U", 'Ф': "F", 'Х': "Kh", 'Ц': "Ts", 'Ч': "Ch", 'Ш': "Sh", 'Щ': "Shch", 'Ъ': "", 'Ы': "Y", 'Ь': "", 'Э': "E", 'Ю': "Yu", 'Я': "Ya", '—': "--", '–': "-", '«': "\"", '»': "\"", '\u00a0': " ", '…': "...", '•': "*", '©': "(c)", '®': "(r)", '™': "(tm)", } if s, ok := m[r]; ok { return s } if r <= 0xFF { return string(r) } return "?" } // defaultPDFPath путь для PDF рядом с md файлом func defaultPDFPath(mdPath string) string { ext := filepath.Ext(mdPath) return strings.TrimSuffix(mdPath, ext) + ".pdf" } // sanitizePDF — заглушка, реальная обработка в движке func sanitizePDF(s string) string { return s }