grimoir/pdf.go
2026-07-04 13:04:33 +03:00

905 lines
23 KiB
Go
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.01.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 }