Todas las publicaciones

PDF contables en Go: diario, mayor y balance de comprobación

Genera PDF contables en Go: libro diario, libro mayor y balance de comprobación desde un solo slice de asientos. Código gpdf completo con saltos de página.

Publicado el 14 de noviembre de 2026
por Taiki Noda

TL;DR

Un solo slice de asientos basta para producir un libro diario, un libro mayor y un balance de comprobación desde el mismo programa en Go. Con gpdf v1.0.17, el main.go completo ocupa 240 líneas. Incluye tablas con líneas, importes alineados a la derecha, filas de encabezado que se repiten tras cada salto de página y números de página 1 / 3. Sin Chromium, sin CGO, sin licencia de herramienta de informes.

Un libro diario de un mes (67 asientos) ocupa 2 páginas y tarda unos 9 ms en un Apple M1. Con 10,000 asientos son 170 páginas y unos 0.7 segundos.

Un informe es una agregación, no una tabla

Cuando alguien pregunta cómo generar informes contables en Go, la primera duda suele ser cómo dibujar las líneas de la cuadrícula. El trabajo no está ahí.

Un libro diario, un libro mayor y un balance de comprobación parecen tres documentos distintos, pero salen de los mismos datos. El diario lista los asientos en orden de fecha. El mayor toma esos mismos asientos, los reagrupa por cuenta y añade un saldo acumulado. El balance de comprobación reduce cada cuenta a una fila. La diferencia entre los tres es un GROUP BY distinto.

Así que el diseño es un tipo de datos, tres funciones de agregación y una función de renderizado. La función de renderizado es el c.Table de gpdf, y es delgada. Por eso este artículo dedica la mayor parte de su extensión a la agregación.

Está escrito para quien construye sistemas de contabilidad, facturación o gastos en Go y se encontró con las palabras "salida de informes" en los requisitos.

El ejemplo usa contabilidad japonesa: los informes se llaman 仕訳帳 (libro diario), 総勘定元帳 (libro mayor) y 合計残高試算表 (balance de comprobación), y los nombres de las cuentas están en japonés. La estructura es la misma partida doble que se usa en todas partes. Mantener el texto en japonés también muestra que la salida CJK no necesita ningún tratamiento especial más allá de registrar una fuente.

El modelo de datos: un solo tipo de asiento

type Entry struct {
	Date, Debit, Credit, Memo string
	Amount                    int64
}

Un asiento es una cuenta del debe, una cuenta del haber y un importe. El importe es un int64 en yenes enteros. Con float64, el día que tu balance de comprobación descuadre por 1 pasarás la noche buscándolo. En un mundo donde 0.1 + 0.2 no es 0.3, comprobar que el debe es igual al haber no funciona.

Las cuentas se describen con dos tablas.

var accounts = []string{"現金", "普通預金", "売掛金", "買掛金", "資本金", "売上高", "仕入高", "支払家賃"}

var debitSide = map[string]bool{"現金": true, "普通預金": true, "売掛金": true, "仕入高": true, "支払家賃": true}

accounts es el orden de presentación: caja, bancos, cuentas por cobrar, cuentas por pagar, capital, ventas, compras, alquiler. El mayor y el balance de comprobación lo siguen. debitSide marca las cuentas que aumentan por el debe, que son los activos y los gastos. Los pasivos, el patrimonio y los ingresos aumentan por el haber. El mayor necesita esta dirección para calcular la columna de saldo.

Esto es una simplificación. Los asientos reales no siempre son uno a uno. Un asiento de nómina tiene una línea en el debe y tres en el haber: bancos, retención del impuesto sobre la renta y retención de la seguridad social. Para admitir asientos compuestos, cambia Entry por un número de comprobante más un slice de líneas. Las funciones de agregación pasan entonces a recorrer líneas, y el código de renderizado no cambia en absoluto.

El código completo

Coloca NotoSansJP-Regular.ttf en un directorio donde hayas ejecutado go mod init y guarda esto como main.go. Probado con gpdf v1.0.17.

go get github.com/gpdf-dev/gpdf
package main

import (
	"fmt"
	"log"
	"os"
	"sort"
	"strconv"

	"github.com/gpdf-dev/gpdf/document"
	"github.com/gpdf-dev/gpdf/pdf"
	"github.com/gpdf-dev/gpdf/template"
)

// Entry es un asiento del diario. Los importes son yenes enteros en int64, nunca float64.
type Entry struct {
	Date, Debit, Credit, Memo string
	Amount                    int64
}

// Plan de cuentas en orden de presentación: caja, bancos, cuentas por cobrar,
// cuentas por pagar, capital, ventas, compras, gasto de alquiler.
var accounts = []string{"現金", "普通預金", "売掛金", "買掛金", "資本金", "売上高", "仕入高", "支払家賃"}

// Cuentas de saldo deudor (activos y gastos). El resto son de saldo acreedor.
var debitSide = map[string]bool{"現金": true, "普通預金": true, "売掛金": true, "仕入高": true, "支払家賃": true}

func yen(n int64) string {
	if n == 0 {
		return ""
	}
	s := strconv.FormatInt(n, 10)
	for i := len(s) - 3; i > 0; i -= 3 {
		s = s[:i] + "," + s[i:]
	}
	return s
}

func newDoc(font []byte, title string) *template.Document {
	doc := template.New(
		template.WithPageSize(document.A4),
		template.WithMargins(document.UniformEdges(document.Mm(15))),
		template.WithFont("NotoSansJP", font),
		template.WithDefaultFont("NotoSansJP", 9),
	)
	doc.Header(func(p *template.PageBuilder) {
		p.AutoRow(func(r *template.RowBuilder) {
			r.Col(6, func(c *template.ColBuilder) {
				c.Text(title, template.FontSize(16))
			})
			r.Col(6, func(c *template.ColBuilder) {
				c.Text("株式会社サンプル商事", template.AlignRight())
				c.Text("自 2026年4月1日 至 2026年4月30日", template.AlignRight())
			})
		})
		p.AutoRow(func(r *template.RowBuilder) {
			r.Col(12, func(c *template.ColBuilder) { c.Spacer(document.Mm(4)) })
		})
	})
	doc.Footer(func(p *template.PageBuilder) {
		p.AutoRow(func(r *template.RowBuilder) {
			r.Col(12, func(c *template.ColBuilder) {
				c.Text(document.PageNumberPlaceholder+" / "+document.TotalPagesPlaceholder,
					template.AlignCenter(), template.FontSize(8), template.TextColor(pdf.Gray(0.4)))
			})
		})
	})
	return doc
}

func table(c *template.ColBuilder, header []string, rows [][]string, widths []float64, aligns []document.TextAlign) {
	// c.Table no tiene opción de padding de celda en v1.0.17, así que el texto toca
	// el borde. Añade un espacio en el lado alineado para separarlo de la línea.
	pad := func(row []string) []string {
		out := make([]string, len(row))
		for i, s := range row {
			switch {
			case s == "":
			case aligns[i] == document.AlignRight:
				out[i] = s + " "
			case aligns[i] == document.AlignLeft:
				out[i] = " " + s
			default:
				out[i] = s
			}
		}
		return out
	}
	header = pad(header)
	padded := make([][]string, len(rows))
	for i, row := range rows {
		padded[i] = pad(row)
	}
	grid := template.Border(template.BorderWidth(document.Pt(0.5)), template.BorderColor(pdf.Gray(0.3)))
	c.Table(header, padded,
		template.ColumnWidths(widths...),
		template.ColumnAlign(aligns...),
		template.TableHeaderStyle(template.BgColor(pdf.RGBHex(0xEEEEEE))),
		template.WithTableCellBorder(grid),
		template.WithTableBorderCollapse(true),
	)
}

func save(doc *template.Document, name string) {
	b, err := doc.Generate()
	if err != nil {
		log.Fatal(err)
	}
	if err := os.WriteFile(name, b, 0o644); err != nil {
		log.Fatal(err)
	}
}

const L, R = document.AlignLeft, document.AlignRight

// Libro diario (仕訳帳): una fila por asiento, en orden de fecha.
func journal(font []byte, entries []Entry) {
	var total int64
	rows := make([][]string, 0, len(entries)+1)
	for _, e := range entries {
		rows = append(rows, []string{e.Date, e.Debit, yen(e.Amount), e.Credit, yen(e.Amount), e.Memo})
		total += e.Amount
	}
	rows = append(rows, []string{"", "合計", yen(total), "", yen(total), ""})

	doc := newDoc(font, "仕訳帳")
	doc.AddPage().AutoRow(func(r *template.RowBuilder) {
		r.Col(12, func(c *template.ColBuilder) {
			table(c, []string{"日付", "借方科目", "借方金額", "貸方科目", "貸方金額", "摘要"}, rows,
				[]float64{10, 14, 14, 14, 14, 34}, []document.TextAlign{L, L, R, L, R, L})
		})
	})
	save(doc, "journal.pdf")
}

// Libro mayor (総勘定元帳): traspasa cada asiento a sus cuentas y lleva un saldo acumulado.
func ledger(font []byte, entries []Entry) {
	doc := newDoc(font, "総勘定元帳")
	page := doc.AddPage()
	for _, acct := range accounts {
		var bal int64
		var rows [][]string
		for _, e := range entries {
			var dr, cr int64
			var counter string
			switch acct {
			case e.Debit:
				dr, counter = e.Amount, e.Credit
			case e.Credit:
				cr, counter = e.Amount, e.Debit
			default:
				continue
			}
			if debitSide[acct] {
				bal += dr - cr
			} else {
				bal += cr - dr
			}
			rows = append(rows, []string{e.Date, counter, e.Memo, yen(dr), yen(cr), yen(bal)})
		}
		if len(rows) == 0 {
			continue
		}
		page.AutoRow(func(r *template.RowBuilder) {
			r.Col(12, func(c *template.ColBuilder) {
				c.Text(acct, template.FontSize(11))
				c.Spacer(document.Mm(1))
				table(c, []string{"日付", "相手科目", "摘要", "借方", "貸方", "残高"}, rows,
					[]float64{10, 14, 34, 14, 14, 14}, []document.TextAlign{L, L, L, R, R, R})
				c.Spacer(document.Mm(5))
			})
		})
	}
	save(doc, "ledger.pdf")
}

// Balance de comprobación (合計残高試算表): totales de debe y haber por cuenta, más el saldo neto.
func trialBalance(font []byte, entries []Entry) {
	dr, cr := map[string]int64{}, map[string]int64{}
	for _, e := range entries {
		dr[e.Debit] += e.Amount
		cr[e.Credit] += e.Amount
	}
	var sumDr, sumCr, balDr, balCr int64
	var rows [][]string
	for _, a := range accounts {
		var bd, bc int64
		if d := dr[a] - cr[a]; d > 0 {
			bd = d
		} else {
			bc = -d
		}
		rows = append(rows, []string{yen(bd), yen(dr[a]), a, yen(cr[a]), yen(bc)})
		sumDr, sumCr, balDr, balCr = sumDr+dr[a], sumCr+cr[a], balDr+bd, balCr+bc
	}
	if sumDr != sumCr || balDr != balCr {
		log.Fatalf("out of balance: totals %d/%d balances %d/%d", sumDr, sumCr, balDr, balCr)
	}
	rows = append(rows, []string{yen(balDr), yen(sumDr), "合計", yen(sumCr), yen(balCr)})

	doc := newDoc(font, "合計残高試算表")
	doc.AddPage().AutoRow(func(r *template.RowBuilder) {
		r.Col(12, func(c *template.ColBuilder) {
			table(c, []string{"借方残高", "借方合計", "勘定科目", "貸方合計", "貸方残高"}, rows,
				[]float64{19, 19, 24, 19, 19}, []document.TextAlign{R, R, document.AlignCenter, R, R})
		})
	})
	save(doc, "trial_balance.pdf")
}

func main() {
	font, err := os.ReadFile("NotoSansJP-Regular.ttf")
	if err != nil {
		log.Fatal(err)
	}
	entries := []Entry{
		{"04/01", "普通預金", "資本金", "設立出資", 3000000},
		{"04/01", "現金", "普通預金", "小口現金引出", 100000},
	}
	// En producción esto es una consulta a la BD. Para la demo, genera un mes de asientos.
	for d := 2; d <= 30; d++ {
		day := fmt.Sprintf("04/%02d", d)
		entries = append(entries,
			Entry{day, "仕入高", "買掛金", fmt.Sprintf("商品仕入 伝票 P-%03d", d), int64(40000 + d*1300)},
			Entry{day, "売掛金", "売上高", fmt.Sprintf("商品売上 伝票 S-%03d", d), int64(88000 + d*2100)},
		)
		if d%10 == 0 {
			entries = append(entries,
				Entry{day, "普通預金", "売掛金", "売掛金回収", 600000},
				Entry{day, "買掛金", "普通預金", "買掛金支払", 300000},
			)
		}
	}
	entries = append(entries, Entry{"04/30", "支払家賃", "普通預金", "5月分家賃", 180000})
	sort.SliceStable(entries, func(i, j int) bool { return entries[i].Date < entries[j].Date })

	journal(font, entries)
	ledger(font, entries)
	trialBalance(font, entries)
}

go run . escribe journal.pdf, ledger.pdf y trial_balance.pdf. Esta es la primera página del libro mayor.

Primera página de un libro mayor generado con gpdf: tablas con líneas para las cuentas de caja, bancos y cuentas por cobrar, con columnas de fecha, cuenta de contrapartida, concepto, debe, haber y saldo

El libro diario: definir anchos y alineación

El diario es el más sencillo de los tres. Cada asiento se convierte en una fila, y al final va una fila de totales.

table(c, []string{"日付", "借方科目", "借方金額", "貸方科目", "貸方金額", "摘要"}, rows,
	[]float64{10, 14, 14, 14, 14, 34}, []document.TextAlign{L, L, R, L, R, L})

Se especifican dos cosas. template.ColumnWidths recibe los anchos como porcentajes que suman 100. template.ColumnAlign recibe una alineación por columna: derecha para los importes, izquierda para todo lo demás. ColumnAlign se aplica también a la fila de encabezado, así que el título queda alineado a la derecha sobre sus números.

Los separadores de miles los añade la función yen antes de que el valor llegue a la tabla. gpdf no tiene formato de números. c.Table recibe un [][]string, y dar formato a cada celda es tarea de quien llama. El cero se convierte en una cadena vacía porque, en el mayor, una fila con importe en el debe debe tener la celda del haber en blanco. Un informe lleno de 0 impresos cuesta leerlo.

La fila de totales pone el mismo total bajo el debe y el haber. Con asientos uno a uno eso es cierto por definición. Si lo amplías a asientos compuestos, suma los dos lados por separado.

El libro mayor: traspaso y saldos acumulados

El mayor es el diario traspasado a las cuentas. Si un asiento carga cuentas por cobrar y abona ventas, aparece una vez en la columna del debe del mayor de cuentas por cobrar y otra en la columna del haber del mayor de ventas. Por eso 67 asientos se convierten en 134 filas del mayor.

El código recorre todos los asientos para cada cuenta.

switch acct {
case e.Debit:
	dr, counter = e.Amount, e.Credit
case e.Credit:
	cr, counter = e.Amount, e.Debit
default:
	continue
}

Si la cuenta está en el debe, el importe va a la columna del debe y la cuenta del haber se registra como cuenta de contrapartida. Si está en el haber, al revés. En cualquier otro caso el asiento se omite.

Es un bucle de cuentas × asientos, y 8 × 67 no es nada. Con 300 cuentas y 100,000 asientos, agrupa primero los asientos en un map[string][]Entry. A ese tamaño lo más probable es que la agregación se haga en SQL de todos modos, y Go reciba las filas ya agrupadas por cuenta.

La columna de saldo usa debitSide para decidir la dirección.

if debitSide[acct] {
	bal += dr - cr
} else {
	bal += cr - dr
}

Bancos es una cuenta de saldo deudor, así que sube con los ingresos (debe) y baja con los pagos (haber). Cuentas por pagar es de saldo acreedor, así que ocurre lo contrario. Omite esta bifurcación y obtendrás un mayor de cuentas por pagar cuyo saldo se vuelve cada vez más negativo. Los números son aritméticamente correctos y aun así el equipo de contabilidad lo devolverá, lo que lo convierte en uno de los bugs más difíciles de explicar.

Como apunte, no hay ninguna razón lógica para que el debe esté a la izquierda y el haber a la derecha. Es una convención que sobrevive desde que la partida doble tomó forma en la Italia del siglo XV, y hay que memorizarla. Las palabras tampoco ayudan: "debe" y "haber" describen la posición de la otra parte, que es lo contrario de lo que espera la mayoría de los principiantes. Llamar a las variables dr y cr al menos evita que el código aumente la confusión.

En cuanto al layout, cada cuenta añade un page.AutoRow.

page.AutoRow(func(r *template.RowBuilder) {
	r.Col(12, func(c *template.ColBuilder) {
		c.Text(acct, template.FontSize(11))
		c.Spacer(document.Mm(1))
		table(c, /* ... */)
		c.Spacer(document.Mm(5))
	})
})

Nombre de la cuenta, tabla, espaciado, apilados ocho veces. Nada en este código calcula dónde termina una página.

El balance de comprobación: fallar antes de renderizar

El balance de comprobación muestra el total del debe y el total del haber por cuenta, y coloca la diferencia en el lado que corresponda. Por convención, el nombre de la cuenta va en el centro, con el debe a la izquierda y el haber a la derecha, así que las columnas son saldo deudor, total del debe, cuenta, total del haber y saldo acreedor.

Las líneas más importantes de este informe van antes de dibujar la tabla.

if sumDr != sumCr || balDr != balCr {
	log.Fatalf("out of balance: totals %d/%d balances %d/%d", sumDr, sumCr, balDr, balCr)
}

Un balance de comprobación existe para detectar errores de traspaso. Si el total del debe y el total del haber no coinciden, hay un asiento roto en algún sitio. No tiene sentido maquetar con cuidado un PDF descuadrado. Mejor detenerse.

En los datos de ejemplo los totales son 11,269,600 yenes y los saldos son 7,389,600 yenes, y ambos lados coinciden. Con asientos solo uno a uno, tienen que coincidir. Estas tres líneas empiezan a importar en el momento en que los datos vienen de una base de datos. En producción, devuelve un error en lugar de llamar a log.Fatalf, para poder averiguar qué cuenta está descuadrada.

Saltos de página: nada que hacer

El código anterior no tiene lógica de saltos de página. No cuenta filas ni mira la altura restante.

Cuando una tabla no cabe en el espacio que queda, el motor de layout de gpdf divide el cuerpo y vuelve a dibujar la fila de encabezado al inicio de la página siguiente. La página 2 del diario empieza con la fila de encabezado, y lo mismo ocurre con la continuación de la tabla de cuentas por cobrar en el mayor. El título del informe y el periodo de doc.Header, y el número de página de doc.Footer, aparecen en todas las páginas. ¿Cómo hago que una tabla abarque varias páginas? explica el mecanismo.

El número de página son dos placeholders dentro de una cadena normal.

c.Text(document.PageNumberPlaceholder+" / "+document.TotalPagesPlaceholder, ...)

El total se rellena cuando termina la paginación. Consulta ¿Cómo poner número de página X de Y en el pie de gpdf?.

Mediciones

Estas cifras son solo del libro diario, con asientos de venta generados en distintas cantidades. Apple M1, Go 1.25.2, gpdf v1.0.17. El tiempo va desde tener los bytes de la fuente en memoria hasta que el PDF queda escrito en disco.

AsientosPáginasTiempoTamaño de archivo
6828.8 ms146 KB
1,0001768 ms1.9 MB
10,000170~0.7 s19 MB

8.8 ms para 68 asientos parece lento al lado de los 13 µs por página que gpdf da como benchmark. La mayor parte de ese tiempo es la fuente: Noto Sans JP es un TTF de 9.6 MB, y se analiza y se reduce a subconjunto en cada ejecución. Más allá de eso, cada fila adicional cuesta unos 70 µs y el crecimiento es lineal. En un proceso nocturno que produce los informes mensuales de toda una empresa, la consulta a la base de datos será el cuello de botella antes que la generación del PDF.

El tamaño de archivo merece decirse sin rodeos. Cada página pesa unos 115 KB. gpdf v1.0.17 comprime los streams de fuentes e imágenes, pero no los operadores de dibujo de la página, y un informe con borde en cada celda tiene muchos operadores. 19 MB para 10,000 filas es demasiado para un adjunto de correo. Dividir por meses, o quitar la cuadrícula y usar filas zebra con template.TableStripe, son las salidas prácticas.

A qué renunciamos

Padding de celda. c.Table en v1.0.17 no tiene opción de padding dentro de la celda. Con bordes de celda, el texto toca la línea. La función table del ejemplo añade un espacio en el lado alineado de cada celda. No es bonito, pero funciona. Con rayas zebra en lugar de bordes el problema no aparece.

Subtotales de página y filas de arrastre. Los libros en papel terminan tradicionalmente cada página con una fila de "suma y sigue" y empiezan la siguiente con "suma anterior". Para hacerlo, el código de agregación tiene que saber qué filas caen en qué página. gpdf decide los saltos de página en el momento del layout, así que la API builder no puede decírtelo. Puedes fijar el número de filas por página y saltar de página tú mismo, pero eso significa renunciar a la paginación automática. Todavía no hemos encontrado una buena forma de hacerlo.

Requisitos legales. Este código produce PDF que parecen libros contables. Si cumplen las normas de conservación de registros de tu jurisdicción es otra cuestión. En Japón, por ejemplo, los libros creados en un sistema contable se conservan por lo general como datos conforme a la Ley de Conservación de Libros Electrónicos, y el PDF es una salida para consulta o presentación. Las normas locales sobre libros contables y facturación electrónica de cada país son, del mismo modo, una cuestión aparte. Consulta la guía de la Agencia Tributaria Nacional (en japonés) o a tu contable.

FAQ

¿Necesito una herramienta de informes para generar PDF contables en Go? No para informes basados en tablas, como un libro diario, un mayor o un balance de comprobación. Pasa un [][]string al c.Table de gpdf y obtienes bordes, anchos de columna, alineación a la derecha, saltos de página automáticos y filas de encabezado repetidas. gpdf tiene licencia MIT y cero dependencias externas, así que tampoco hay licencia de runtime.

¿Puedo usar orientación horizontal? Sí. Cambia la opción a template.WithPageSize(document.A4.Landscape()). Un diario con conceptos largos se lee mejor en horizontal. Los anchos de columna son porcentajes, así que no cambia nada más en el código.

¿Funciona con texto que no sea japonés? Sí. Sustituye las cadenas y registra con template.WithFont una fuente que cubra tu sistema de escritura. Para japonés, Noto Sans JP, IPAex Gothic y Source Han Sans funcionan igual. Consulta ¿Cómo incrusto una fuente japonesa en gpdf?.

¿Se pueden mostrar los importes negativos en rojo o entre paréntesis? Los paréntesis o el signo menos son cuestión de formato de cadenas, así que resuélvelos en yen. c.Table no tiene opción de color de texto por celda. Para cifras en rojo, construye un document.Table directamente, como se describe en Tablas en PDFs con Go: anchos, rayas zebra, saltos de página.

¿Puedo generar PDF/A para archivo a largo plazo? Sí. Pasa gpdf.WithPDFA() a gpdf.NewDocument. PDF/A exige fuentes incrustadas, algo que un informe CJK ya tiene.

Prueba gpdf

gpdf es una librería de PDF para Go. MIT, cero dependencias, CJK nativo.

go get github.com/gpdf-dev/gpdf

⭐ Star en GitHub · Leer los docs

Lecturas siguientes

Si encuentras una forma limpia de hacer las filas de arrastre, abre un issue en GitHub.