905 lines
23 KiB
Go
Executable file
905 lines
23 KiB
Go
Executable file
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 }
|