Help us shape the future of Go! Take the 2026 Go Developer Survey

fb2

package module
v0.1.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 18, 2026 License: BSD-3-Clause Imports: 6 Imported by: 0

README

fb2

FictionBook 2 (.fb2) into richdoc. Pure Go, CGO-free, including GOOS=js. richdoc is the only dependency — everything else is the standard library.

doc, err := fb2.Parse(src, fb2.Limits{})

FictionBook is an XML ebook format, widely used in Russia and eastern Europe and almost unknown elsewhere — which is the reason anybody converts one.

It is one file, and that is the whole difference

Unlike EPUB there is no ZIP, no package document and no spine: a FictionBook is a single XML file, and its images are base64 inside that same file. That makes it the one ebook format a reader can do with encoding/xml and nothing else, which is why this is a small library rather than a stack.

<body>, <section> nesting, which is where a heading's level comes from — there is no <h2> in FB2, only a section inside a section
<title>, <subtitle> a heading
<p> a paragraph
<poem>, <stanza> a paragraph per stanza, its verses separated by line breaks — which is what a stanza is: lines that belong together and are not paragraphs
<cite>, <epigraph>, <annotation> a block quotation
<table> a table, its header row the one FB2 marked with <th>
<image> an image, with the <binary> it names turned into a data URL
<strong>, <emphasis> strong and emphasis; also strikethrough, code and links

⛔ Decisions worth knowing before you read the output

A title broken over several <p> is one heading. FB2 does that for the line breaks, and a document with three headings where a reader sees one navigates wrongly.

An image becomes its bytes. They are in the same file — that is what FB2 is — so handing back #cover.jpg would be handing back a name nothing can resolve once the file is closed. A binary past Limits.MaxBinary is left out rather than truncated: half a JPEG is not a smaller picture, and the href and alt text still say what was wanted.

Only the first row can be a header, and only when every cell is <th>. A long table repeats its headings partway down; the repeat is a row, or the last one silently replaces the first and everything above it loses its heading.

A second <body name="notes"> comes out as a section of its own, with that name as a heading. FB2 links to a note by id, and resolving those would mean deciding where in a paragraph a reader wants it.

Namespaces are not matched. FB2's own is http://www.gribuser.ru/xml/fictionbook/2.0, and producers write it, mis-write it and leave it out — a reader that insisted would refuse files every other reader opens. The format has no second vocabulary to confuse it with, so a local name is unambiguous here in a way it would not be in OOXML.

An element it does not know is still content: its text becomes a paragraph. FB2 has extensions, and losing them silently is worse than rendering them plainly.

What it deliberately drops: an <empty-line/> is vertical space and the paragraph break is already there; <sub> and <sup> have no richdoc equivalent, so their text is kept and their position is not — losing the 2 of H2O to say so would be the worse trade.

⛔ The depth limit is a pass of its own

The stack a deeply nested file overflows belongs to encoding/xml, not to this package, so the depth is counted over tokens, before anything builds a tree. A guard placed where our own code recurses never runs: go-odf/odf learned that with a 115 MiB allocation inside the decoder while its own counter stood at two.

Limits{} is the default everywhere: 256 levels of nesting, 64 MiB per decoded binary.

⛔ One picture is one copy, and a sum of lengths is not an allocation

An FB2 carries its images as base64 in the same file, and every <image> used to build its own data:...;base64,... string — so a picture named a thousand times was allocated a thousand times. Measured with runtime.MemStats, not inferred:

file source heap held parse
one 100 KiB image, 1 reference 137 KB 6.6 KB 3 ms
the same image, 1000 references 166 KB 139 MB 197 ms
one 512 KiB image, 2000 references 757 KB 1.41 GB 2.3 s

The growth is linear in the number of references, so a 10 MB file reaches tens of gigabytes. One shared string per binary brings that last row to 360 KB and 15 ms — the same document, 3900× less memory.

The instrument had to be corrected first. Summing the URL lengths said "1846×" both before and after the fix, because the same string counted a thousand times sums the same either way. A sum of lengths is not an allocation; the heap is. For the same reason the test asserts the backing array (unsafe.StringData) rather than equality — an equality check passes just as well on a thousand identical copies, which is exactly the defect.

And this is why there is no MaxTotalBinary, though go-richdoc/epub has a MaxTotalBytes beside its per-entry ceiling. That reader inflates ZIP entries, which can be far smaller compressed than inflated, so its total cannot be deduced from the file's size. An FB2's binaries are base64 in the file, so the sum of its distinct binaries is at most four thirds of the source — bounded by the input once the repeats are shared. The difference between the siblings is in the format, not in how carefully each was written.

This reader is fuzzed

FuzzParse (v0.1.1) asserts no panic, a document OR an error and never neither, determinism (binaries and their URLs live in maps, and Go randomises map iteration on purpose), the depth limit, and — the property that earns the target — that the document is linear in the file: the distinct image payloads, counted by backing array, add up to no more than twice the source.

14.3M executions over 431 interesting inputs found nothing. That count is high because malformed XML is refused cheaply: the number says the fuzzer got many attempts, not that the reader was stressed in many ways.

One representation, not two

The first version of the element type had both a typed Children field and a raw-token field, each tagged ,any. encoding/xml fills only one of those, silently — every test came back with an empty document and no error. The tokens are the single source of truth now, and both the block children and the inline run are derived from them.

That is also the right shape for the job: mixed content is what a paragraph is. a <strong>bold</strong> word is three things in a row, and any typed struct separates the text from the markup and loses where each went.

How it is checked

100% of statements with no uncovered block, go vet and gofmt clean, and builds for amd64, arm64, riscv64, loong64, ppc64le, s390x and js/wasm. 19 mutations were run against the package and every one was caught — two of them only after a test that asserts Header directly (a helper printing a header and a first row identically could not tell them apart) and a fixture whose table repeats its <th> partway down.

The rest of the family

richdoc is the model · markdown reads and writes it · rst · latex · data is CSV, JSON, XML and plain text.

go-pdfkit turns a richdoc document into a PDF, which is what makes .fb2 → PDF a two-step with no format-specific code in between.

Licence

BSD-3-Clause.

Documentation

Overview

Package fb2 reads FictionBook 2 (.fb2) into the richdoc document model.

FictionBook is an XML ebook format, widely used in Russia and eastern Europe and almost unknown elsewhere — which is the reason anybody converts one. Unlike EPUB it is a SINGLE XML FILE: no ZIP, no package document, no spine, and its images are base64 inside the same file. That makes it the one ebook format a reader can do with encoding/xml and nothing else.

What it maps onto

<body>, <section>    nesting, which is where a heading's LEVEL comes from
<title>, <subtitle>  a heading
<p>                  a paragraph
<poem>, <stanza>     a paragraph per stanza, its verses separated by
                     line breaks — which is what a stanza IS
<cite>, <epigraph>   a block quotation
<table>              a table
<image>              an image, with the <binary> it names turned into a
                     data URL, because the bytes are in the same file and
                     a reader that dropped them would lose the cover
<strong>, <emphasis> strong and emphasis; also strikethrough, code, links

What it does not keep

An <empty-line/> is vertical space between paragraphs, and richdoc has no word for it: the paragraph break is already there. <sub> and <sup> have no richdoc equivalent either, so their text is kept and their position is not — losing "H2O"'s subscript is better than losing the 2.

A second <body name="notes"> is the footnotes, and it comes out as a section of its own at the end rather than being woven into the text: FB2 links to a note by id, and resolving those would mean deciding where in a paragraph a reader wants it.

Nothing here is CGO.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Parse

func Parse(src []byte, lim Limits) (*richdoc.Document, error)

Parse reads a FictionBook into a document.

Types

type Limits

type Limits struct {
	// MaxDepth is how deeply elements may nest.
	//
	// ⛔ The stack a deep file overflows belongs to encoding/xml and not to
	// this package, so this is checked in a token pass of its own BEFORE the
	// document is built — a guard placed where our own code recurses never
	// runs. go-odf/odf learned that with a 115 MiB allocation inside the
	// decoder while its own counter stood at two.
	MaxDepth int

	// MaxBinary is the largest a single <binary> may be once decoded, in
	// bytes. An FB2 carries its images as base64 in the same file, so a
	// small file can still name a large picture.
	MaxBinary int
}

Limits bound what a file may ask for. A zero field means the default, so Limits{} is the default everywhere.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL