Files
pdf-odin-llm/examples/pdfrender/main.odin
T

268 lines
8.6 KiB
Odin
Raw Normal View History

2026-09-25 23:32:29 +02:00
// pdfrender rasterises a page and writes it out as a binary PPM (P6) or PNG.
//
// It is the smallest program that exercises the whole library: open, page
// tree, content interpreter, every device below it, and back out as pixels.
// Like pdfinfo it also keeps the public surface honest -- it is compiled by
// scripts/check.sh, so a procedure a renderer needs that is accidentally
// @(private) breaks the build here rather than in a consumer's project.
//
// odin run examples/pdfrender -- file.pdf # page 1 -> out.ppm
// odin run examples/pdfrender -- file.pdf -page=7 -scale=2 -o=seven.png
//
// Options:
// -page=N 1-based page number (default 1)
// -scale=F device pixels per point (default 1)
// -o=PATH output file; .png writes a PNG, anything else a PPM
// -no-annots draw the content stream only
// -gray report ink coverage instead of writing a file
package main
import "core:fmt"
import "core:hash"
import "core:os"
import "core:strconv"
import "core:strings"
import pdf "../.."
Options :: struct {
path: string,
out: string,
page: int,
scale: f64,
no_annots: bool,
stat_only: bool,
}
main :: proc() {
opts, ok := parse_args()
if !ok {
fmt.eprintfln("usage: %s <file.pdf> [-page=N] [-scale=F] [-o=out.ppm|out.png] [-no-annots] [-gray]",
len(os.args) > 0 ? os.args[0] : "pdfrender")
os.exit(2)
}
if !render(opts) {
os.exit(1)
}
}
parse_args :: proc() -> (opts: Options, ok: bool) {
opts = Options{page = 1, scale = 1, out = "out.ppm"}
for arg in os.args[1:] {
switch {
case strings.has_prefix(arg, "-page="):
n, nok := strconv.parse_int(arg[len("-page="):])
if !nok || n < 1 { return opts, false }
opts.page = n
case strings.has_prefix(arg, "-scale="):
f, fok := strconv.parse_f64(arg[len("-scale="):])
if !fok || f <= 0 { return opts, false }
opts.scale = f
case strings.has_prefix(arg, "-o="):
opts.out = arg[len("-o="):]
case arg == "-no-annots":
opts.no_annots = true
case arg == "-gray":
opts.stat_only = true
case strings.has_prefix(arg, "-"):
return opts, false
case:
if opts.path != "" { return opts, false }
opts.path = arg
}
}
return opts, opts.path != ""
}
render :: proc(opts: Options) -> bool {
doc, err := pdf.open_file(opts.path)
if err != .None {
fmt.eprintfln("%s: %s", opts.path, pdf.error_string(err))
return false
}
defer pdf.close(doc)
if opts.page > pdf.page_count(doc) {
fmt.eprintfln("%s: page %d of %d", opts.path, opts.page, pdf.page_count(doc))
return false
}
page, perr := pdf.page_get(doc, opts.page - 1)
if perr != .None {
fmt.eprintfln("%s: page %d: %s", opts.path, opts.page, pdf.error_string(perr))
return false
}
// The scale rides on top of the page flip and /Rotate, which
// page_base_matrix applies for us; the bitmap comes out page_size big.
bmp, rerr := pdf.render_page(&page, {
ctm = pdf.scale_matrix(opts.scale, opts.scale),
no_annotations = opts.no_annots,
})
defer pdf.bitmap_destroy(&bmp)
// A render that reports an error still hands back everything it managed to
// draw, so say so and write the pixels anyway rather than throwing them
// away -- that is the difference between "damaged" and "unreadable".
if rerr != .None {
fmt.eprintfln("%s: page %d rendered with %s", opts.path, opts.page, pdf.error_string(rerr))
if bmp.pixels == nil { return false }
}
ink := ink_fraction(&bmp)
fmt.printfln("%s page %d: %dx%d px, %.2f%% ink", opts.path, opts.page,
bmp.width, bmp.height, ink * 100)
if opts.stat_only {
return true
}
data: []byte
defer delete(data)
if strings.has_suffix(strings.to_lower(opts.out, context.temp_allocator), ".png") {
data = encode_png(&bmp)
} else {
data = encode_ppm(&bmp)
}
if data == nil {
fmt.eprintfln("%s: could not encode the image", opts.out)
return false
}
if werr := os.write_entire_file(opts.out, data); werr != nil {
fmt.eprintfln("%s: %v", opts.out, werr)
return false
}
fmt.printfln("wrote %s (%d bytes)", opts.out, len(data))
return true
}
// ink_fraction is the share of pixels that are not the untouched background.
// A page that renders pure white is nearly always a bug, so this is the one
// number worth printing without opening the image.
ink_fraction :: proc(bmp: ^pdf.Bitmap) -> f64 {
if bmp.width <= 0 || bmp.height <= 0 { return 0 }
inked := 0
for y in 0 ..< bmp.height {
row := pdf.bitmap_row(bmp, y)
for x in 0 ..< bmp.width {
p := row[x * bmp.n:]
if p[0] != 255 || (bmp.n >= 3 && (p[1] != 255 || p[2] != 255)) {
inked += 1
}
}
}
return f64(inked) / f64(bmp.width * bmp.height)
}
// ---------------------------------------------------------------------------
// Writers
//
// PPM because it is four lines and every image tool reads it; PNG because
// nothing else does without one. Both drop alpha: render_page starts the page
// opaque white unless asked otherwise, so there is none to keep.
// ---------------------------------------------------------------------------
encode_ppm :: proc(bmp: ^pdf.Bitmap) -> []byte {
b: strings.Builder
strings.builder_init(&b)
fmt.sbprintf(&b, "P6\n%d %d\n255\n", bmp.width, bmp.height)
for y in 0 ..< bmp.height {
row := pdf.bitmap_row(bmp, y)
for x in 0 ..< bmp.width {
r, g, bl := pixel_rgb(bmp, row, x)
strings.write_byte(&b, r)
strings.write_byte(&b, g)
strings.write_byte(&b, bl)
}
}
return b.buf[:]
}
encode_png :: proc(bmp: ^pdf.Bitmap) -> []byte {
raw: [dynamic]byte
defer delete(raw)
for y in 0 ..< bmp.height {
row := pdf.bitmap_row(bmp, y)
append(&raw, 0) // filter type 0: none
for x in 0 ..< bmp.width {
r, g, bl := pixel_rgb(bmp, row, x)
append(&raw, r, g, bl)
}
}
out: [dynamic]byte
append(&out, 0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a)
ihdr: [13]byte
put_u32(ihdr[0:], u32(bmp.width))
put_u32(ihdr[4:], u32(bmp.height))
ihdr[8] = 8 // bit depth
ihdr[9] = 2 // colour type 2: truecolour
png_chunk(&out, "IHDR", ihdr[:])
idat := zlib_stored(raw[:])
defer delete(idat)
png_chunk(&out, "IDAT", idat)
png_chunk(&out, "IEND", nil)
return out[:]
}
// zlib_stored wraps bytes in a zlib stream of uncompressed deflate blocks.
//
// core:compress has an inflater and no deflater, and a PNG's IDAT must be a
// zlib stream whatever is inside it -- so this is the honest way to write one
// without a compressor: stored blocks are a legal encoding that every decoder
// accepts. The file is the size of the pixels; PPM is the better choice if
// that matters, and this exists so the example can hand over something a
// browser opens.
zlib_stored :: proc(data: []byte) -> []byte {
MAX_BLOCK :: 65535
out: [dynamic]byte
append(&out, 0x78, 0x01) // CM=8, CINFO=7, FCHECK making it a multiple of 31
remaining := data
for {
n := min(len(remaining), MAX_BLOCK)
last: byte = len(remaining) - n == 0 ? 1 : 0
append(&out, last, byte(n), byte(n >> 8), byte(~u16(n)), byte(~u16(n) >> 8))
append(&out, ..remaining[:n])
remaining = remaining[n:]
if last == 1 { break }
}
sum := hash.adler32(data)
m := len(out)
resize(&out, m + 4)
put_u32(out[m:], sum)
return out[:]
}
png_chunk :: proc(out: ^[dynamic]byte, tag: string, data: []byte) {
n := len(out)
resize(out, n + 4)
put_u32(out[n:], u32(len(data)))
append(out, tag)
append(out, ..data)
// The CRC covers the tag and the data but not the length.
crc := hash.crc32(out[n + 4:])
m := len(out)
resize(out, m + 4)
put_u32(out[m:], crc)
}
put_u32 :: proc(dst: []byte, v: u32) {
dst[0] = byte(v >> 24)
dst[1] = byte(v >> 16)
dst[2] = byte(v >> 8)
dst[3] = byte(v)
}
// A rendered page is RGBA8, but render_page_to accepts any component count, so
// handle the grey and RGB cases rather than assuming.
pixel_rgb :: proc(bmp: ^pdf.Bitmap, row: []byte, x: int) -> (r, g, b: byte) {
p := row[x * bmp.n:]
if bmp.n >= 3 {
return p[0], p[1], p[2]
}
return p[0], p[0], p[0]
}