initial commit

This commit is contained in:
2026-09-25 23:32:29 +02:00
commit fff8b677f8
114 changed files with 84233 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
// pdfinfo prints the structure of a PDF: version, page count, metadata, page
// geometry, and how much content each page carries.
//
// It exists as much to keep the library's public surface honest as to be
// useful. Everything here is reachable from outside the package, so if a
// procedure a consumer needs is accidentally @(private), this example stops
// compiling and scripts/check.sh catches it.
//
// odin run examples/pdfinfo -- file.pdf
package main
import "core:fmt"
import "core:os"
import pdf "../.."
main :: proc() {
if len(os.args) < 2 {
fmt.eprintfln("usage: %s <file.pdf> [more.pdf ...]", os.args[0])
os.exit(2)
}
failures := 0
for path in os.args[1:] {
if !report(path) {
failures += 1
}
}
if failures > 0 {
os.exit(1)
}
}
report :: proc(path: string) -> bool {
doc, err := pdf.open_file(path)
if err != .None {
fmt.eprintfln("%s: %s", path, pdf.error_string(err))
return false
}
defer pdf.close(doc)
major, minor := pdf.document_version(doc)
fmt.printfln("%s", path)
fmt.printfln(" PDF %d.%d, %d pages, %d objects", major, minor,
pdf.page_count(doc), pdf.object_count(doc))
print_info(doc)
total := 0
for i in 0 ..< pdf.page_count(doc) {
page, perr := pdf.page_get(doc, i)
if perr != .None {
fmt.printfln(" page %d: %s", i + 1, pdf.error_string(perr))
continue
}
content, cerr := pdf.page_content(&page)
defer delete(content)
if cerr == .None {
total += len(content)
}
// Only the first few, or a thousand-page standard buries everything.
if i < 5 {
b := pdf.page_bounds(&page)
w, h := pdf.page_size(&page, 1)
fmt.printfln(" page %d: %.1f x %.1f pt, rotate %d, %d x %d px, %d bytes of content",
i + 1, pdf.rect_width(b), pdf.rect_height(b), page.rotate, w, h, len(content))
}
}
fmt.printfln(" %d bytes of content stream decoded, %d KB parsed",
total, pdf.document_memory_used(doc) / 1024)
// A file that needed repairs still opened; say what was worked around, so
// the difference between "clean" and "damaged but readable" is visible.
if doc.is_damaged {
fmt.printfln(" %d repairs:", len(doc.repairs))
shown := 0
for r in doc.repairs {
if shown >= 5 { break }
fmt.printfln(" %s", pdf.repair_string(r.kind))
shown += 1
}
}
return true
}
// Every value in the document information dictionary is a text string, so it
// has to go through decode_text_string: the common encodings are UTF-16BE with
// a byte-order mark and PDFDocEncoding, and neither is UTF-8.
print_info :: proc(doc: ^pdf.Document) {
info := pdf.dict_dict(doc, doc.trailer, "Info")
if info == nil { return }
for key in ([]string{"Title", "Author", "Subject", "Creator", "Producer", "CreationDate", "ModDate"}) {
raw := pdf.dict_bytes(doc, info, key)
if len(raw) == 0 { continue }
text := pdf.decode_text_string(raw)
defer delete(text)
fmt.printfln(" %-13s %s", key, text)
}
}
+267
View File
@@ -0,0 +1,267 @@
// 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]
}
+119
View File
@@ -0,0 +1,119 @@
// pdftext prints the text of a page, or of a whole document.
//
// The text comes off the same interpreter that draws the page, so what it
// prints is what a renderer would have painted: invisible OCR text under a
// scan comes out, text drawn as vector paths does not, and the reading order
// is the order the glyphs were placed in. Like the other examples it is
// compiled by scripts/check.sh, which is what keeps the public API honest.
//
// odin run examples/pdftext -- file.pdf # page 1
// odin run examples/pdftext -- file.pdf -page=all # every page
// odin run examples/pdftext -- file.pdf -page=3 -stats
//
// Options:
// -page=N|all 1-based page number, or every page (default 1)
// -stats print per-page character counts instead of the text
package main
import "core:fmt"
import "core:os"
import "core:strconv"
import "core:strings"
import "core:unicode/utf8"
import pdf "../.."
main :: proc() {
path, page, stats, ok := parse_args()
if !ok {
fmt.eprintfln("usage: %s <file.pdf> [-page=N|all] [-stats]",
len(os.args) > 0 ? os.args[0] : "pdftext")
os.exit(2)
}
doc, err := pdf.open_file(path)
if err != .None {
fmt.eprintfln("%s: %s", path, pdf.error_string(err))
os.exit(1)
}
defer pdf.close(doc)
n := pdf.page_count(doc)
first, last := page, page
if page == ALL_PAGES {
first, last = 0, n - 1
}
if first < 0 || last >= n {
fmt.eprintfln("%s: page %d of %d", path, first + 1, n)
os.exit(1)
}
total, with_text, replacements := 0, 0, 0
for i in first ..= last {
p, perr := pdf.page_get(doc, i)
if perr != .None {
fmt.eprintfln("page %d: %s", i + 1, pdf.error_string(perr))
continue
}
// extract_text hands back a string the caller owns.
text, terr := pdf.extract_text(&p)
defer delete(text)
if terr != .None {
fmt.eprintfln("page %d: %s", i + 1, pdf.error_string(terr))
}
total += len(text)
if len(text) > 0 { with_text += 1 }
replacements += count_replacements(text)
if stats {
fmt.printfln("page %d: %d bytes, %d runes, %d U+FFFD",
i + 1, len(text), utf8.rune_count(text), count_replacements(text))
continue
}
if first != last {
fmt.printfln("--- page %d ---", i + 1)
}
fmt.println(text)
}
if stats || first != last {
fmt.printfln("%d of %d pages yielded text, %d bytes, %d U+FFFD",
with_text, last - first + 1, total, replacements)
}
}
ALL_PAGES :: -1
parse_args :: proc() -> (path: string, page: int, stats: bool, ok: bool) {
for arg in os.args[1:] {
switch {
case arg == "-page=all":
page = ALL_PAGES
case strings.has_prefix(arg, "-page="):
n, nok := strconv.parse_int(arg[len("-page="):])
if !nok || n < 1 { return "", 0, false, false }
page = n - 1
case arg == "-stats":
stats = true
case strings.has_prefix(arg, "-"):
return "", 0, false, false
case:
if path != "" { return "", 0, false, false }
path = arg
}
}
return path, page, stats, path != ""
}
// A code the font could not map to Unicode extracts as U+FFFD, so counting
// them is the cheapest measure of how well the /ToUnicode and encoding chain
// did on a file. A page that is mostly replacement characters has text on it
// that nothing can search or copy.
count_replacements :: proc(s: string) -> int {
n := 0
for r in s {
if r == utf8.RUNE_ERROR { n += 1 }
}
return n
}