LLM-Generated Epub Reader In FreeBASIC (DOS/Linux)
howtophil |
PRO |
10/01/26 05:01:24 PM UTC (Edited) |
0 ⭐ |
23962 👁️ |
Never ⏰ |
[ai, Linux, MS-DOS, llm, MSDOS, Epub, freebasic, freedos, ereader, LLMisTrash]
' Generated by Deepseek chat bot 2026/09/21
' https://chat.deepseek.com/
'
' PUBLIC DOMAIN BECAUSE GENERATED CODE CANNOT
' BE COPYRIGHTED OR COPYLEFTED
'
' Since I was pestered to "really try" "AI" (LLM is not AI),
' I had it generate an epub reader written in FreeBASIC that
' works in Linux terminal and MS-DOS/FreeDOS.
'
' I completed the task of having the LLM generate something
' sort of like what I wanted...
'
' It was a painfully stupid process of the machine trying to
' use reserved words as variables and other such mistakes that
' even beginners would not make. This is because the LLM does
' not know anything, it merely generates based on statistics.
'
' I have tried. I have found the process and output wanting.
'
' Every LLM is trash that produces garbage.
'
' However, since I hate to waste the resources, here's the source
' code for anyone who wants it. I have, sadly, already boiled oceans
' and burned forests for this experiment. People might as well have
' the product made from the ill-collected data mined by these LLM
' companies.
'
' Modified 2026/09/22 So it saves the place you've read to in the
' book, in a bookmark file next to the epub.
'
' Modified 2026/09/29 CP437 substitution for DOS: em/en dashes,
' curly quotes, and ellipsis map to ASCII; accented Latin letters
' map to native CP437 bytes. Linux output remains UTF-8.
'
' Modified 2026/09/30 Added `goto_section` and `next_section`,
' both keyed to the table of contents. Added a persistent
' bookmark file. `show`, `goto_section`, `next_section`, and
' the interactive TOC screen all take or display TOC indexes.
' `toc` opens the TOC screen. Added `toc_print` and `meta_print`
' for non-interactive use. Removed the old `list` command.
' Image alt text is now emitted inline in brackets. When a TOC
' entry has an anchor, the anchor wins over any explicit skip
' argument. Bookmark stores TOC index, spine section, and skip.
'
' ---------------------------------------------------------------------
'
' "Bugfix" attempt by kimi.ai code generation
'
' Modified 2026/10/01 Bug fixes (see the comments at each change):
' CP437 case labels for e-acute/e-grave were &h00A9/&h00A8 instead of
' &h00E9/&h00E8. Empty stored DEFLATE blocks underflowed the copy loop.
' hlit/hdist were not capped at 286/30 (one-byte stack overwrite).
' Malformed numeric character references could reach chr() with an
' illegal value or emit an overlong UTF-8 NUL. The last buffered line
' kept its newline and printed a spurious blank row. Whitespace-only
' source lines vanished, merging paragraphs. CR bytes survived into
' the output. Unterminated script/style blocks rendered as text.
' Percent-encoded hrefs were not decoded, and TOC hrefs were resolved
' against the OPF directory instead of the NCX / nav file's directory.
' Anchor search could false-match data-id attributes. A named-entity
' table now covers Latin-1 letters and common symbols. Fixed a
' next_section/bookmark mixup that could store a TOC index in the
' bookmark's spine-section field. Removed dead skip/total variables.
'
' =====================================================================
' epubrd.bas - Minimal EPUB reader in FreeBASIC
' =====================================================================
' Self-contained DEFLATE (RFC 1951). No external libraries.
' Portable across 32-bit MS-DOS and 64-bit Linux/host.
'
' Build (Linux/host): fbc epubrd.bas
' Build Cross Compile (MS-DOS): fbc -target dos epubrd.bas
' Build (MS-DOS): fbc epubrd.bas
'
' Commands:
' epubrd <file.epub> resume from bookmark
' epubrd <file.epub> meta title and author, paged
' epubrd <file.epub> meta_print title and author, plain
' epubrd <file.epub> toc interactive TOC screen
' epubrd <file.epub> toc_print TOC listing, plain
' epubrd <file.epub> show [n] [skip] read TOC entry n
' epubrd <file.epub> goto_section <n> same as `show <n>`
' epubrd <file.epub> next_section <n> [skip]
'
' Reader keys:
' [space] or [Enter] next page
' [b] or [B] previous page
' [t] or [T] table of contents screen
' [q] or [Q] quit
' [ctrl-C] quit
' any other key ignored
' On quit, prints the command to resume at the top line on
' screen and writes the bookmark file.
'
' TOC screen keys:
' digits then [Enter] jump to that entry
' [space], [Enter] next page of TOC (empty buffer)
' [b], [B] previous page of TOC
' [q], [Q], [Esc] return to the reader at the same page
' [ctrl-C] quit the program
' any other key ignored
'
' Resume and skip:
' `show <n> [skip]`: read TOC entry n as text. If the entry has
' an anchor (`file.xhtml#frag`), jumps to it and ignores `skip`.
' If the entry has no anchor, starts at line 0 or at `skip`.
' `show` with no argument reads the bookmark, if any.
'
' next_section:
' Read TOC entry n, then continue into n+1, n+2, and so on
' until the end of the TOC or until you quit. `skip` applies
' only to the first entry, and only if that entry has no
' anchor. On quit, the resume hint names the TOC entry you
' were in.
'
' Bookmark:
' On quit, writes `<basename>.bmk` next to the epub:
' line 1 TOC index of the reading position
' line 2 spine section index
' line 3 line skip within the section
' `epubrd <file>` with no command, or `show` with no
' arguments, resumes from the bookmark in spine terms,
' bypassing TOC resolution. Delete the .bmk to start over.
' For `book.epub` the bookmark is `book.bmk`.
'
' Text extraction:
' Tags are stripped, entities decoded, and block-level tags
' (`<p>`, `<div>`, `<br>`, headings, list items, table rows,
' block quotes, preformatted blocks, and sectioning tags)
' produce newlines. `<script>` and `<style>` blocks are
' dropped entirely. `<img alt="...">` contributes the alt
' text inline, in brackets. No CSS, no layout, no emphasis
' markers.
'
' Environment variables:
' EPUBREAD_MAX max epub size in bytes (default 16777216)
' EPUBREAD_COLS wrap width, 20..78 (default 78)
' EPUBREAD_ROWS terminal rows (default 25); lines/page = rows-4
' EPUBREAD_LINES content lines per page, overrides rows-4
'
' All file-mapped integers use `ulong` (fixed 32 bits on every
' target), not `uinteger` (pointer-sized, 64 bits on x86_64).
' =====================================================================
' ---------------------------------------------------------------------
' Constants
' ---------------------------------------------------------------------
const NULL_PTR as ubyte ptr = 0
const LOCAL_SIG as ulong = &h04034b50
const CDIR_SIG as ulong = &h02014b50
const EOCD_SIG as ulong = &h06054b50
const MAX_EOCD_SEARCH as ulong = 65557
const DEFAULT_MAX_EPUB as ulong = 16777216
const DEFAULT_WRAP as integer = 78
const DEFAULT_ROWS as integer = 25
const DEFAULT_PAGE_LINES as integer = 21
const MAX_MANIFEST as integer = 1023
const MAX_TOC as integer = 255
const KEY_CTRL_C as integer = 3
const KEY_ESC as integer = 27
const KEY_ENTER as integer = 13
const KEY_SPACE as integer = 32
const KEY_B_LOWER as integer = 98
const KEY_B_UPPER as integer = 66
const KEY_Q_LOWER as integer = 113
const KEY_Q_UPPER as integer = 81
const KEY_T_LOWER as integer = 116
const KEY_T_UPPER as integer = 84
' =====================================================================
' Section 1: ZIP container structures (on-disk layout)
' =====================================================================
' These types map directly onto bytes in the .zip file. `field = 1`
' forces byte packing so sizeof() matches the on-disk record size,
' and every multi-byte field uses a fixed-width type (ulong, ushort)
' so the layout is identical on 32-bit and 64-bit builds.
type ZipLocalHeader field = 1
signature as ulong
version as ushort
flags as ushort
method as ushort
modtime as ushort
moddate as ushort
crc32 as ulong
compsize as ulong
uncompsize as ulong
namelen as ushort
extralen as ushort
end type
type ZipCentralHeader field = 1
signature as ulong
versionmade as ushort
versionneed as ushort
flags as ushort
method as ushort
modtime as ushort
moddate as ushort
crc32 as ulong
compsize as ulong
uncompsize as ulong
namelen as ushort
extralen as ushort
commentlen as ushort
diskstart as ushort
intattr as ushort
extattr as ulong
offset as ulong
end type
type ZipEndRecord field = 1
signature as ulong
disknum as ushort
cddisk as ushort
diskentries as ushort
totalentries as ushort
cdsize as ulong
cdoffset as ulong
commentlen as ushort
end type
' =====================================================================
' Section 2: Bit reader for DEFLATE streams
' =====================================================================
type BitReader
rawbuf as ubyte ptr
rawlen as ulong
pos as ulong
bitbuf as ulong
bitcnt as integer
end type
sub br_init(br as BitReader ptr, buf as ubyte ptr, sz as ulong)
br->rawbuf = buf
br->rawlen = sz
br->pos = 0
br->bitbuf = 0
br->bitcnt = 0
end sub
' Pull n bits, LSB first. Zero-fills past end of buffer.
function br_bits(br as BitReader ptr, n as integer) as ulong
while br->bitcnt < n
dim as ulong b = 0
if br->pos < br->rawlen then
b = br->rawbuf[br->pos]
br->pos += 1
end if
br->bitbuf or= b shl br->bitcnt
br->bitcnt += 8
wend
dim as ulong v = br->bitbuf and ((1u shl n) - 1)
br->bitbuf shr= n
br->bitcnt -= n
return v
end function
sub br_align(br as BitReader ptr)
dim as integer drop = br->bitcnt and 7
br->bitbuf shr= drop
br->bitcnt -= drop
end sub
' =====================================================================
' Section 3: Huffman decoding
' =====================================================================
type HuffTable
counts(0 to 15) as integer
symbols(0 to 287) as integer
nsym as integer
end type
sub huff_build(h as HuffTable ptr, lengths as ubyte ptr, n as integer)
dim as integer i
for i = 0 to 15
h->counts(i) = 0
next
for i = 0 to n - 1
h->counts(lengths[i]) += 1
next
h->counts(0) = 0
dim as integer offs(0 to 15)
offs(0) = 0
for i = 1 to 15
offs(i) = offs(i-1) + h->counts(i-1)
next
for i = 0 to n - 1
if lengths[i] <> 0 then
h->symbols(offs(lengths[i])) = i
offs(lengths[i]) += 1
end if
next
h->nsym = n
end sub
' Canonical Huffman decode. Returns the symbol or -1 on error.
function huff_decode(br as BitReader ptr, h as HuffTable ptr) as integer
dim as integer code = 0, first = 0, index = 0
for ln as integer = 1 to 15
code or= cast(integer, br_bits(br, 1))
dim as integer count = h->counts(ln)
if code - first < count then
return h->symbols(index + code - first)
end if
index += count
first = (first + count) shl 1
code shl= 1
next
return -1
end function
' =====================================================================
' Section 4: DEFLATE (RFC 1951)
' =====================================================================
dim shared as integer LENGTH_BASE(0 to 28) = { _
3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, _
35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258 }
dim shared as integer LENGTH_EXTRA(0 to 28) = { _
0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, _
3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0 }
dim shared as integer DIST_BASE(0 to 29) = { _
1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, _
257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577 }
dim shared as integer DIST_EXTRA(0 to 29) = { _
0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, _
7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13 }
' Inflate a raw DEFLATE stream (no zlib header).
' Returns the number of uncompressed bytes written, or -1 on error.
function inflate_raw(src as ubyte ptr, srcsize as ulong, dst as ubyte ptr, dstsize as ulong) as integer
dim as BitReader br
br_init(@br, src, srcsize)
dim as ulong outpos = 0
do
dim as ulong bfinal = br_bits(@br, 1)
dim as ulong btype = br_bits(@br, 2)
select case btype
case 0
' Stored block
br_align(@br)
dim as ulong ln = br_bits(@br, 16)
dim as ulong nln = br_bits(@br, 16)
if (ln xor &hFFFF) <> nln then return -1
' Count in a signed type: with ln = 0 (a legal empty stored
' block) `for i as ulong = 0 to ln - 1` wraps the end bound
' to ~4 billion and the loop never ends normally.
dim as long stored_n = cast(long, ln)
for i as long = 0 to stored_n - 1
if outpos >= dstsize then return -1
dst[outpos] = cast(ubyte, br_bits(@br, 8))
outpos += 1
next
case 1
' Fixed Huffman
dim as ubyte litlen(0 to 287)
for i as integer = 0 to 143: litlen(i) = 8: next
for i as integer = 144 to 255: litlen(i) = 9: next
for i as integer = 256 to 279: litlen(i) = 7: next
for i as integer = 280 to 287: litlen(i) = 8: next
dim as ubyte distlen(0 to 31)
for i as integer = 0 to 31: distlen(i) = 5: next
dim as HuffTable lit_h, dist_h
huff_build(@lit_h, @litlen(0), 288)
huff_build(@dist_h, @distlen(0), 32)
do
dim as integer sym = huff_decode(@br, @lit_h)
if sym < 0 then return -1
if sym < 256 then
if outpos >= dstsize then return -1
dst[outpos] = cast(ubyte, sym)
outpos += 1
elseif sym = 256 then
exit do
else
dim as integer li = sym - 257
if li < 0 or li > 28 then return -1
dim as uinteger length = LENGTH_BASE(li) + br_bits(@br, LENGTH_EXTRA(li))
dim as integer dsym = huff_decode(@br, @dist_h)
if dsym < 0 or dsym > 29 then return -1
dim as uinteger dist = DIST_BASE(dsym) + br_bits(@br, DIST_EXTRA(dsym))
if dist > outpos then return -1
for j as ulong = 0 to length - 1
if outpos >= dstsize then return -1
dst[outpos] = dst[outpos - dist]
outpos += 1
next
end if
loop
case 2
' Dynamic Huffman
dim as ulong hlit = br_bits(@br, 5) + 257
dim as ulong hdist = br_bits(@br, 5) + 1
dim as ulong hclen = br_bits(@br, 4) + 4
' RFC 1951 caps these at 286 and 30. Without the check a
' crafted stream (hlit = 288, hdist = 32) makes hlit + hdist
' exceed the lengths() array by one byte.
if hlit > 286 orelse hdist > 30 then return -1
dim as ubyte order(0 to 18) = { _
16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15 }
dim as ubyte cl_lengths(0 to 18)
for i as integer = 0 to 18: cl_lengths(i) = 0: next
for i as ulong = 0 to hclen - 1
cl_lengths(order(i)) = cast(ubyte, br_bits(@br, 3))
next
dim as HuffTable cl_h
huff_build(@cl_h, @cl_lengths(0), 19)
dim as ubyte lengths(0 to 318)
dim as ulong total = hlit + hdist
dim as ulong n = 0
while n < total
dim as integer sym = huff_decode(@br, @cl_h)
if sym < 0 then return -1
if sym < 16 then
lengths(n) = cast(ubyte, sym)
n += 1
elseif sym = 16 then
if n = 0 then return -1
dim as ubyte prev = lengths(n-1)
dim as ulong rep = 3 + br_bits(@br, 2)
while rep > 0 andalso n < total
lengths(n) = prev
n += 1
rep -= 1
wend
elseif sym = 17 then
dim as ulong rep = 3 + br_bits(@br, 3)
while rep > 0 andalso n < total
lengths(n) = 0
n += 1
rep -= 1
wend
elseif sym = 18 then
dim as ulong rep = 11 + br_bits(@br, 7)
while rep > 0 andalso n < total
lengths(n) = 0
n += 1
rep -= 1
wend
else
return -1
end if
wend
dim as HuffTable lit_h, dist_h
huff_build(@lit_h, @lengths(0), hlit)
huff_build(@dist_h, @lengths(hlit), hdist)
do
dim as integer sym = huff_decode(@br, @lit_h)
if sym < 0 then return -1
if sym < 256 then
if outpos >= dstsize then return -1
dst[outpos] = cast(ubyte, sym)
outpos += 1
elseif sym = 256 then
exit do
else
dim as integer li = sym - 257
if li < 0 or li > 28 then return -1
dim as uinteger length = LENGTH_BASE(li) + br_bits(@br, LENGTH_EXTRA(li))
dim as integer dsym = huff_decode(@br, @dist_h)
if dsym < 0 or dsym > 29 then return -1
dim as uinteger dist = DIST_BASE(dsym) + br_bits(@br, DIST_EXTRA(dsym))
if dist > outpos then return -1
for j as ulong = 0 to length - 1
if outpos >= dstsize then return -1
dst[outpos] = dst[outpos - dist]
outpos += 1
next
end if
loop
case else
return -1
end select
if bfinal <> 0 then exit do
loop
return cast(integer, outpos)
end function
' =====================================================================
' Section 5: In-memory ZIP archive
' =====================================================================
type ZipEntry
zname as string
zmethod as ushort
zcompsize as ulong
zuncompsize as ulong
zoffset as ulong
end type
type ZipArchive
rawbuf as ubyte ptr
rawlen as ulong
entries(any) as ZipEntry
nentries as integer
end type
function dos_max_bytes() as ulong
dim as string s = environ("EPUBREAD_MAX")
if s = "" then return DEFAULT_MAX_EPUB
dim as ulong v = val(s)
if v < 65536 then v = 65536
return v
end function
' Forward declaration: zip_open's error paths release the archive.
declare sub zip_close(byref za as ZipArchive)
' Read the whole file into memory and parse the central directory.
' Returns 0 on success, -1 on any failure.
function zip_open(byref za as ZipArchive, filename as string) as integer
dim as integer f = freefile
if open(filename for binary as #f) <> 0 then
print "zip_open: open failed for [" & filename & "]"
return -1
end if
za.rawlen = lof(f)
if za.rawlen = 0 then
close #f
print "zip_open: zero-length file"
return -1
end if
if za.rawlen > dos_max_bytes() then
close #f
print "zip_open: file too large: " & str(za.rawlen) & " bytes"
return -1
end if
za.rawbuf = callocate(za.rawlen + 1)
if za.rawbuf = NULL_PTR then
close #f
print "zip_open: callocate failed for " & str(za.rawlen) & " bytes"
return -1
end if
if za.rawlen > 0 then
get #f, , *za.rawbuf, za.rawlen
' LOC is the byte position of the last byte read, so it equals
' the number of bytes actually fetched. A value short of the
' file size means the archive buffer is incomplete.
dim as ulong got_bytes = loc(f)
close #f
if got_bytes <> za.rawlen then
print "zip_open: short read (" & str(got_bytes) & " of " & str(za.rawlen) & " bytes)"
zip_close(za)
return -1
end if
else
close #f
end if
if za.rawlen < sizeof(ZipEndRecord) then
print "zip_open: smaller than end record"
zip_close(za)
return -1
end if
' Locate the end-of-central-directory record.
dim as ulong searchstart = 0
if za.rawlen > MAX_EOCD_SEARCH then searchstart = za.rawlen - MAX_EOCD_SEARCH
dim as integer epos = -1
for i as integer = cast(integer, za.rawlen) - 22 to cast(integer, searchstart) step -1
if za.rawbuf[i] = &h50 andalso _
za.rawbuf[i+1] = &h4b andalso _
za.rawbuf[i+2] = &h05 andalso _
za.rawbuf[i+3] = &h06 then
epos = i
exit for
end if
next
if epos < 0 then
print "zip_open: no end-of-central-directory signature found"
zip_close(za)
return -1
end if
dim as ZipEndRecord ptr eocd = cast(ZipEndRecord ptr, za.rawbuf + epos)
za.nentries = eocd->totalentries
redim za.entries(0 to za.nentries - 1)
' Walk the central directory.
dim as ulong cdpos = eocd->cdoffset
for i as integer = 0 to za.nentries - 1
if cdpos + sizeof(ZipCentralHeader) > za.rawlen then
print "zip_open: cdpos past end at entry " & str(i)
zip_close(za)
return -1
end if
dim as ZipCentralHeader ptr cdhdr = cast(ZipCentralHeader ptr, za.rawbuf + cdpos)
if cdhdr->signature <> CDIR_SIG then
print "zip_open: bad central header sig at entry " & str(i) & _
" = " & hex(cdhdr->signature)
zip_close(za)
return -1
end if
dim as string nm = ""
for j as ulong = 0 to cdhdr->namelen - 1
nm &= chr(za.rawbuf[cdpos + sizeof(ZipCentralHeader) + j])
next
za.entries(i).zname = nm
za.entries(i).zmethod = cdhdr->method
za.entries(i).zcompsize = cdhdr->compsize
za.entries(i).zuncompsize = cdhdr->uncompsize
za.entries(i).zoffset = cdhdr->offset
cdpos += sizeof(ZipCentralHeader) + cdhdr->namelen + cdhdr->extralen + cdhdr->commentlen
next
return 0
end function
sub zip_close(byref za as ZipArchive)
if za.rawbuf <> NULL_PTR then deallocate(za.rawbuf)
za.rawbuf = NULL_PTR
erase za.entries
end sub
' Return the index of the entry with this exact name, or -1.
function zip_find(byref za as ZipArchive, nm as string) as integer
for i as integer = 0 to za.nentries - 1
if za.entries(i).zname = nm then return i
next
return -1
end function
' Extract a named entry straight into a string's backing buffer.
' No intermediate uncompressed allocation, no copy.
function zip_extract_string(byref za as ZipArchive, nm as string) as string
dim as integer idx = zip_find(za, nm)
if idx < 0 then
print "zip_extract_string: not found: " & nm
return ""
end if
dim as ZipEntry ptr e = @za.entries(idx)
if e->zoffset + sizeof(ZipLocalHeader) > za.rawlen then return ""
dim as ZipLocalHeader ptr localhdr = cast(ZipLocalHeader ptr, za.rawbuf + e->zoffset)
if localhdr->signature <> LOCAL_SIG then return ""
dim as ulong dataoff = e->zoffset + sizeof(ZipLocalHeader) + localhdr->namelen + localhdr->extralen
dim as ulong csize = e->zcompsize
if dataoff + csize > za.rawlen then
csize = localhdr->compsize
if dataoff + csize > za.rawlen then return ""
end if
dim as ulong usize = e->zuncompsize
if usize = 0 then usize = localhdr->uncompsize
if usize = 0 then usize = 1
dim as string s = space(usize)
dim as ubyte ptr out_ptr = cast(ubyte ptr, strptr(s))
select case e->zmethod
case 0
dim as ulong n = iif(csize < usize, csize, usize)
for i as ulong = 0 to n - 1
out_ptr[i] = za.rawbuf[dataoff + i]
next
case 8
dim as integer got = inflate_raw(za.rawbuf + dataoff, csize, out_ptr, usize)
if got <> cast(integer, usize) then return ""
case else
return ""
end select
return s
end function
' =====================================================================
' Section 6: String, XML, and CP437 helpers
' =====================================================================
' Map a Unicode code point to the byte sequence that should be emitted
' to the current output device. On DOS, that is a sequence of CP437
' bytes. On Linux/host, it is the UTF-8 encoding.
function unicode_to_output(code as integer) as string
#ifdef __FB_DOS__
' CP437 has no em dash, en dash, curly quotes, or ellipsis.
' Substitute the ASCII equivalents, which every DOS display
' shows correctly regardless of the active code page.
select case code
case &h00A0 : return chr(32) ' nbsp -> space
case &h2014 : return "-" ' em dash
case &h2013 : return "-" ' en dash
case &h2018 : return "'" ' left single quote
case &h2019 : return "'" ' right single quote
case &h201C : return """" ' left double quote
case &h201D : return """" ' right double quote
case &h2026 : return "..." ' ellipsis
case &h2022 : return "*" ' bullet
case &h2122 : return "(TM)" ' trademark
' Accented Latin letters that CP437 does have.
case &h00E9 : return chr(&h82) ' é
case &h00E8 : return chr(&h8A) ' è
case &h00E0 : return chr(&h85) ' à
case &h00E2 : return chr(&h83) ' â
case &h00E7 : return chr(&h87) ' ç
case &h00EA : return chr(&h88) ' ê
case &h00EB : return chr(&h89) ' ë
case &h00EE : return chr(&h8B) ' î
case &h00EF : return chr(&h8C) ' ï
case &h00F4 : return chr(&h93) ' ô
case &h00F6 : return chr(&h94) ' ö
case &h00FB : return chr(&h96) ' û
case &h00FC : return chr(&h81) ' ü
case &h00E1 : return chr(&hA0) ' á
case &h00ED : return chr(&hA1) ' í
case &h00F3 : return chr(&hA2) ' ó
case &h00FA : return chr(&hA3) ' ú
case &h00F1 : return chr(&hA4) ' ñ
case &h00D1 : return chr(&hA5) ' Ñ
case else
if code > 0 andalso code < 128 then
return chr(code)
end if
return "?"
end select
#else
if code < 1 then
return "?"
elseif code < 128 then
return chr(code)
elseif code < 2048 then
return chr(&hC0 or (code shr 6)) & _
chr(&h80 or (code and &h3F))
elseif code < 65536 then
return chr(&hE0 or (code shr 12)) & _
chr(&h80 or ((code shr 6) and &h3F)) & _
chr(&h80 or (code and &h3F))
else
return "?"
end if
#endif
end function
function str_lower(s as string) as string
dim as string r = s
for i as integer = 1 to len(r)
dim as integer c = asc(r, i)
if c >= asc("A") andalso c <= asc("Z") then
mid(r, i, 1) = chr(c + 32)
end if
next
return r
end function
function str_trim(s as string) as string
dim as integer a = 1, b = len(s)
while a <= b andalso (asc(s, a) = 32 or asc(s, a) = 9 or asc(s, a) = 10 or asc(s, a) = 13)
a += 1
wend
while b >= a andalso (asc(s, b) = 32 or asc(s, b) = 9 or asc(s, b) = 10 or asc(s, b) = 13)
b -= 1
wend
if b < a then return ""
return mid(s, a, b - a + 1)
end function
' Map a named character reference (the text between "&" and ";") to a
' Unicode code point, or -1 when it is not in the built-in table.
' Covers the Latin-1 letters, punctuation, and the common HTML 4
' symbols most often found in epubs, so that é and friends do
' not pass through literally. Decoding goes through
' unicode_to_output(), so the DOS build still gets CP437 bytes.
function entity_code(entname as string) as integer
select case entname
case "nbsp" : return &h00A0
case "iexcl" : return &h00A1
case "cent" : return &h00A2
case "pound" : return &h00A3
case "curren" : return &h00A4
case "yen" : return &h00A5
case "brvbar" : return &h00A6
case "sect" : return &h00A7
case "uml" : return &h00A8
case "copy" : return &h00A9
case "ordf" : return &h00AA
case "laquo" : return &h00AB
case "not" : return &h00AC
case "shy" : return &h00AD
case "reg" : return &h00AE
case "macr" : return &h00AF
case "deg" : return &h00B0
case "plusmn" : return &h00B1
case "sup2" : return &h00B2
case "sup3" : return &h00B3
case "acute" : return &h00B4
case "micro" : return &h00B5
case "para" : return &h00B6
case "middot" : return &h00B7
case "cedil" : return &h00B8
case "sup1" : return &h00B9
case "ordm" : return &h00BA
case "raquo" : return &h00BB
case "frac14" : return &h00BC
case "frac12" : return &h00BD
case "frac34" : return &h00BE
case "iquest" : return &h00BF
case "Agrave" : return &h00C0
case "Aacute" : return &h00C1
case "Acirc" : return &h00C2
case "Atilde" : return &h00C3
case "Auml" : return &h00C4
case "Aring" : return &h00C5
case "AElig" : return &h00C6
case "Ccedil" : return &h00C7
case "Egrave" : return &h00C8
case "Eacute" : return &h00C9
case "Ecirc" : return &h00CA
case "Euml" : return &h00CB
case "Igrave" : return &h00CC
case "Iacute" : return &h00CD
case "Icirc" : return &h00CE
case "Iuml" : return &h00CF
case "ETH" : return &h00D0
case "Ntilde" : return &h00D1
case "Ograve" : return &h00D2
case "Oacute" : return &h00D3
case "Ocirc" : return &h00D4
case "Otilde" : return &h00D5
case "Ouml" : return &h00D6
case "times" : return &h00D7
case "Oslash" : return &h00D8
case "Ugrave" : return &h00D9
case "Uacute" : return &h00DA
case "Ucirc" : return &h00DB
case "Uuml" : return &h00DC
case "Yacute" : return &h00DD
case "THORN" : return &h00DE
case "szlig" : return &h00DF
case "agrave" : return &h00E0
case "aacute" : return &h00E1
case "acirc" : return &h00E2
case "atilde" : return &h00E3
case "auml" : return &h00E4
case "aring" : return &h00E5
case "aelig" : return &h00E6
case "ccedil" : return &h00E7
case "egrave" : return &h00E8
case "eacute" : return &h00E9
case "ecirc" : return &h00EA
case "euml" : return &h00EB
case "igrave" : return &h00EC
case "iacute" : return &h00ED
case "icirc" : return &h00EE
case "iuml" : return &h00EF
case "eth" : return &h00F0
case "ntilde" : return &h00F1
case "ograve" : return &h00F2
case "oacute" : return &h00F3
case "ocirc" : return &h00F4
case "otilde" : return &h00F5
case "ouml" : return &h00F6
case "divide" : return &h00F7
case "oslash" : return &h00F8
case "ugrave" : return &h00F9
case "uacute" : return &h00FA
case "ucirc" : return &h00FB
case "uuml" : return &h00FC
case "yacute" : return &h00FD
case "thorn" : return &h00FE
case "yuml" : return &h00FF
case "OElig" : return &h0152
case "oelig" : return &h0153
case "Scaron" : return &h0160
case "scaron" : return &h0161
case "Yuml" : return &h0178
case "fnof" : return &h0192
case "circ" : return &h02C6
case "tilde" : return &h02DC
case "ensp" : return &h2002
case "emsp" : return &h2003
case "thinsp" : return &h2009
case "dagger" : return &h2020
case "Dagger" : return &h2021
case "permil" : return &h2030
case "lsaquo" : return &h2039
case "rsaquo" : return &h203A
case "euro" : return &h20AC
case else : return -1
end select
end function
' Single-pass entity decoder. UTF-8 input is preserved as-is on Linux;
' on DOS, cp437_fix() converts it after the fact.
function xml_unescape(s as string) as string
dim as string o = ""
dim as integer i = 1
dim as integer n = len(s)
while i <= n
dim as integer c = asc(s, i)
if c = asc("&") then
dim as integer j = instr(i, s, ";")
if j > 0 andalso j - i <= 12 then
dim as string entity = mid(s, i + 1, j - i - 1)
select case entity
case "lt"
o &= "<" : i = j + 1 : continue while
case "gt"
o &= ">" : i = j + 1 : continue while
case "quot"
o &= """" : i = j + 1 : continue while
case "apos"
o &= "'" : i = j + 1 : continue while
case "amp"
o &= "&" : i = j + 1 : continue while
case "mdash"
o &= unicode_to_output(&h2014) : i = j + 1 : continue while
case "ndash"
o &= unicode_to_output(&h2013) : i = j + 1 : continue while
case "hellip"
o &= unicode_to_output(&h2026) : i = j + 1 : continue while
case "lsquo"
o &= unicode_to_output(&h2018) : i = j + 1 : continue while
case "rsquo"
o &= unicode_to_output(&h2019) : i = j + 1 : continue while
case "ldquo"
o &= unicode_to_output(&h201C) : i = j + 1 : continue while
case "rdquo"
o &= unicode_to_output(&h201D) : i = j + 1 : continue while
case "nbsp"
o &= unicode_to_output(&h00A0) : i = j + 1 : continue while
end select
' Named references beyond the XML five and the handful
' handled above (Latin-1 letters, common punctuation,
' symbols). Unknown names fall through as literal text.
dim as integer ecode = entity_code(entity)
if ecode >= 0 then
o &= unicode_to_output(ecode) : i = j + 1 : continue while
end if
if left(entity, 1) = "#" then
dim as string num = mid(entity, 2)
dim as integer code = -1
if left(num, 1) = "x" orelse left(num, 1) = "X" then
code = val("&h" & mid(num, 2))
elseif num <> "" then
code = val(num)
end if
' Reject empty, negative, and out-of-range values so a
' malformed reference can never reach chr() with an
' illegal argument, and never emit an overlong UTF-8
' encoding of U+0000.
if code < 1 orelse code > &h10FFFF then code = -1
if code < 0 then
o &= "?"
else
o &= unicode_to_output(code)
end if
i = j + 1
continue while
end if
end if
o &= "&"
i += 1
else
o &= chr(c)
i += 1
end if
wend
return o
end function
' Convert a string that may contain raw UTF-8 to CP437 on DOS.
' On Linux this is a no-op.
function cp437_fix(s as string) as string
#ifndef __FB_DOS__
return s
#endif
dim as string o = ""
dim as integer i = 1
dim as integer n = len(s)
while i <= n
dim as ubyte b0 = asc(s, i)
if b0 < 128 then
o &= chr(b0)
i += 1
continue while
end if
if (b0 and &hE0) = &hC0 andalso i + 1 <= n then
dim as ubyte b1 = asc(s, i + 1)
if (b1 and &hC0) = &h80 then
dim as integer code = ((b0 and &h1F) shl 6) or (b1 and &h3F)
o &= unicode_to_output(code)
i += 2
continue while
end if
end if
if (b0 and &hF0) = &hE0 andalso i + 2 <= n then
dim as ubyte b1 = asc(s, i + 1)
dim as ubyte b2 = asc(s, i + 2)
if (b1 and &hC0) = &h80 andalso (b2 and &hC0) = &h80 then
dim as integer code = ((b0 and &h0F) shl 12) or ((b1 and &h3F) shl 6) or (b2 and &h3F)
o &= unicode_to_output(code)
i += 3
continue while
end if
end if
if (b0 and &hF8) = &hF0 andalso i + 3 <= n then
dim as ubyte b1 = asc(s, i + 1)
dim as ubyte b2 = asc(s, i + 2)
dim as ubyte b3 = asc(s, i + 3)
if (b1 and &hC0) = &h80 andalso (b2 and &hC0) = &h80 andalso (b3 and &hC0) = &h80 then
' Valid four-byte sequence. CP437 has nothing for it, but
' consume the whole sequence so it costs one placeholder
' instead of four.
o &= "?"
i += 4
continue while
end if
end if
' Not a valid UTF-8 lead byte, or a truncated sequence.
' Emit a placeholder and advance one byte.
o &= "?"
i += 1
wend
return o
end function
' Return the value of attrname="..." inside a tag string, or "".
' Does not match attrname if it appears as the tail of another
' attribute name (e.g. `id` inside `grid`).
function xml_attr(tag as string, attrname as string) as string
dim as string lt = str_lower(tag)
dim as string an = str_lower(attrname)
dim as integer frompos = 1
do
dim as integer p = instr(frompos, lt, an & "=")
if p = 0 then return ""
' Check the character immediately before the match. If it is
' a letter, digit, underscore, colon, or hyphen, then this
' match is the tail of a longer attribute name and must be
' rejected.
dim as integer boundary_ok = 1
if p > 1 then
dim as integer pc = asc(lt, p - 1)
if (pc >= asc("a") andalso pc <= asc("z")) orelse _
(pc >= asc("0") andalso pc <= asc("9")) orelse _
pc = asc("_") orelse pc = asc(":") orelse pc = asc("-") then
boundary_ok = 0
end if
end if
if boundary_ok = 1 then
dim as integer q = p + len(an) + 1
dim as string quote = mid(tag, q, 1)
if quote <> """" andalso quote <> "'" then
' Not a quoted value. Keep looking.
frompos = p + 1
else
dim as integer e = instr(q + 1, tag, quote)
if e = 0 then return ""
return mid(tag, q + 1, e - q - 1)
end if
else
' Move past this false match and keep looking.
frompos = p + len(an) + 1
end if
loop
end function
type XmlTag
raw as string
tname as string
attrs as string
inner as string
selfclosed as integer
end type
' Find all occurrences of <tagname ...> in s. Fills outtags() and
' returns the count. Only handles non-nested tags.
function xml_find_tags(s as string, tagname as string, outtags() as XmlTag) as integer
dim as integer count = 0
redim outtags(0 to 63)
dim as string tn = str_lower(tagname)
dim as integer i = 1
while i <= len(s)
dim as integer p = instr(i, s, "<")
if p = 0 then exit while
dim as integer e = instr(p, s, ">")
if e = 0 then exit while
dim as string raw = mid(s, p, e - p + 1)
dim as string inner = mid(s, p + 1, e - p - 1)
dim as string inner_lc = str_lower(inner)
dim as integer space_pos = instr(inner_lc, " ")
dim as integer slash_pos = instr(inner_lc, "/")
dim as integer cut = len(inner_lc)
if space_pos > 0 andalso space_pos - 1 < cut then cut = space_pos - 1
if slash_pos > 0 andalso slash_pos - 1 < cut then cut = slash_pos - 1
if cut < 1 then cut = 1
dim as string firstword = left(inner_lc, cut)
if firstword = tn then
if count > ubound(outtags) then redim preserve outtags(0 to count + 63)
outtags(count).raw = raw
outtags(count).tname = firstword
outtags(count).attrs = mid(inner, cut + 1)
outtags(count).selfclosed = iif(right(raw, 2) = "/>", 1, 0)
if outtags(count).selfclosed = 0 then
dim as integer cp = instr(e, s, "</" & tagname & ">")
if cp = 0 then cp = instr(e, s, "</" & ucase(tagname) & ">")
if cp > 0 then
outtags(count).inner = mid(s, e + 1, cp - e - 1)
end if
end if
count += 1
end if
i = e + 1
wend
return count
end function
' Find the byte offset in xhtml of the element with id="frag".
' Returns -1 if not found. Handles both single and double quotes.
function find_anchor_offset(xhtml as string, frag as string) as integer
if frag = "" then return -1
dim as string needle1 = "id=""" & frag & """"
dim as string needle2 = "id='" & frag & "'"
' A hit is rejected when the character immediately before it is a
' name character (letter, digit, underscore, colon, hyphen), so
' that data-id="frag" does not count as a match.
dim as integer frompos = 1
do
dim as integer p1 = instr(frompos, xhtml, needle1)
dim as integer p2 = instr(frompos, xhtml, needle2)
dim as integer p = p1
if p = 0 orelse (p2 > 0 andalso p2 < p1) then p = p2
if p = 0 then return -1
dim as integer ok = 1
if p > 1 then
dim as integer pc = asc(xhtml, p - 1)
if (pc >= asc("a") andalso pc <= asc("z")) orelse _
(pc >= asc("A") andalso pc <= asc("Z")) orelse _
(pc >= asc("0") andalso pc <= asc("9")) orelse _
pc = asc("_") orelse pc = asc(":") orelse pc = asc("-") then
ok = 0
end if
end if
if ok then return p
frompos = p + 1
loop
end function
' =====================================================================
' Section 7: EPUB structure (OPF, spine, TOC)
' =====================================================================
type SpineItem
idref as string
href as string
mediatype as string
end type
type TocEntry
label as string
href as string
end type
type Epub
za as ZipArchive
opfpath as string
opfdir as string
title as string
author as string
spine(any) as SpineItem
nspine as integer
toc(any) as TocEntry
ntoc as integer
end type
' Join a relative path against a base directory, normalizing ./ and ../.
function path_join(basedir as string, rel as string) as string
if rel = "" then return basedir
if left(rel, 1) = "/" then return mid(rel, 2)
dim as string b = basedir
if b <> "" andalso right(b, 1) <> "/" then b &= "/"
dim as string r = b & rel
dim as string parts(0 to 1023)
dim as integer np = 0
dim as integer i = 1
while i <= len(r)
dim as integer j = i
while j <= len(r) andalso mid(r, j, 1) <> "/"
j += 1
wend
dim as string seg = mid(r, i, j - i)
if seg = ".." then
if np > 0 then np -= 1
elseif seg <> "" andalso seg <> "." then
if np <= ubound(parts) then
parts(np) = seg
np += 1
end if
end if
i = j + 1
wend
dim as string o = ""
for k as integer = 0 to np - 1
if k > 0 then o &= "/"
o &= parts(k)
next
return o
end function
function dir_of(p as string) as string
for i as integer = len(p) to 1 step -1
if mid(p, i, 1) = "/" then return left(p, i - 1)
next
return ""
end function
' Decode percent-escapes (%20 etc.) in a URL path or fragment. Invalid
' or truncated escapes are copied through literally. Zip entry names in
' epubs are the decoded byte strings, so hrefs must be decoded before
' they can be matched against the archive.
function url_decode(s as string) as string
dim as string o = ""
dim as integer i = 1
dim as integer n = len(s)
while i <= n
if asc(s, i) = asc("%") andalso i + 2 <= n then
dim as integer ok = 1
for k as integer = 1 to 2
dim as integer hc = asc(s, i + k)
dim as integer hex_ok = (hc >= asc("0") andalso hc <= asc("9")) orelse _
(hc >= asc("a") andalso hc <= asc("f")) orelse _
(hc >= asc("A") andalso hc <= asc("F"))
if not hex_ok then ok = 0
next
if ok then
o &= chr(cint(val("&h" & mid(s, i + 1, 2))))
i += 3
continue while
end if
end if
o &= chr(asc(s, i))
i += 1
wend
return o
end function
' Return the spine index whose href matches the given href, or -1.
' Comparison ignores any leading "./" or "/" and is exact otherwise.
function spine_index_by_href(byref ep as Epub, href as string) as integer
dim as string h = href
if left(h, 2) = "./" then h = mid(h, 3)
if left(h, 1) = "/" then h = mid(h, 2)
for i as integer = 0 to ep.nspine - 1
dim as string s = ep.spine(i).href
if left(s, 2) = "./" then s = mid(s, 3)
if left(s, 1) = "/" then s = mid(s, 2)
if s = h then return i
next
return -1
end function
' Split "path#frag" into path and frag. If no '#', frag is "".
sub split_href(href as string, byref path as string, byref frag as string)
dim as integer p = instr(href, "#")
if p > 0 then
path = left(href, p - 1)
frag = mid(href, p + 1)
else
path = href
frag = ""
end if
end sub
' Parse container.xml, the OPF, and either NCX or nav.xhtml.
' Returns 0 on success, -1 on failure.
function epub_load(byref ep as Epub, filename as string) as integer
if zip_open(ep.za, filename) <> 0 then return -1
dim as string cont = zip_extract_string(ep.za, "META-INF/container.xml")
if cont = "" then
print "epub_load: cannot read META-INF/container.xml"
zip_close(ep.za)
return -1
end if
dim as XmlTag roots()
dim as integer n = xml_find_tags(cont, "rootfile", roots())
if n = 0 then
print "epub_load: no rootfile in container.xml"
zip_close(ep.za)
return -1
end if
dim as string fullpath = xml_attr(roots(0).raw, "full-path")
if fullpath = "" then
print "epub_load: rootfile has no full-path"
zip_close(ep.za)
return -1
end if
ep.opfpath = fullpath
ep.opfdir = dir_of(fullpath)
dim as string opf = zip_extract_string(ep.za, fullpath)
if opf = "" then
print "epub_load: cannot read opf"
zip_close(ep.za)
return -1
end if
' Metadata
dim as XmlTag tags()
dim as integer c = xml_find_tags(opf, "title", tags())
if c > 0 then ep.title = xml_unescape(str_trim(tags(0).inner))
c = xml_find_tags(opf, "creator", tags())
if c > 0 then ep.author = xml_unescape(str_trim(tags(0).inner))
' Manifest: collect id -> href, media-type
dim as string manifest_ids(0 to MAX_MANIFEST)
dim as string manifest_hrefs(0 to MAX_MANIFEST)
dim as string manifest_types(0 to MAX_MANIFEST)
dim as integer nman = 0
c = xml_find_tags(opf, "item", tags())
for i as integer = 0 to c - 1
dim as string id_ = xml_attr(tags(i).raw, "id")
dim as string hr = xml_attr(tags(i).raw, "href")
dim as string mt = xml_attr(tags(i).raw, "media-type")
if id_ <> "" andalso nman <= MAX_MANIFEST then
manifest_ids(nman) = id_
' Manifest hrefs are URLs: decode percent-escapes so they
' can be matched against the raw byte names of zip entries.
manifest_hrefs(nman) = url_decode(hr)
manifest_types(nman) = mt
nman += 1
end if
next
' Spine: resolve each itemref to a manifest href
c = xml_find_tags(opf, "itemref", tags())
redim ep.spine(0 to iif(c > 0, c - 1, 0))
ep.nspine = 0
for i as integer = 0 to c - 1
dim as string idref = xml_attr(tags(i).raw, "idref")
if idref = "" then continue for
for j as integer = 0 to nman - 1
if manifest_ids(j) = idref then
ep.spine(ep.nspine).idref = idref
ep.spine(ep.nspine).href = path_join(ep.opfdir, manifest_hrefs(j))
ep.spine(ep.nspine).mediatype = manifest_types(j)
ep.nspine += 1
exit for
end if
next
next
' Locate NCX and/or nav.xhtml
dim as string ncxpath = ""
dim as string navpath = ""
for j as integer = 0 to nman - 1
if manifest_types(j) = "application/x-dtbncx+xml" then
ncxpath = path_join(ep.opfdir, manifest_hrefs(j))
end if
if manifest_types(j) = "application/xhtml+xml" andalso navpath = "" then
' Match against the file name, not any substring of the whole
' path, so that e.g. "renavigate.xhtml" is not mistaken for a
' nav document.
dim as string navbase = manifest_hrefs(j)
for k as integer = len(navbase) to 1 step -1
if mid(navbase, k, 1) = "/" then
navbase = mid(navbase, k + 1)
exit for
end if
next
if left(navbase, 3) = "nav" orelse instr(navbase, "nav.") > 0 then
navpath = path_join(ep.opfdir, manifest_hrefs(j))
end if
end if
next
dim as integer ntoc = 0
redim ep.toc(0 to MAX_TOC)
' Prefer NCX.
if ncxpath <> "" then
dim as string ncx = zip_extract_string(ep.za, ncxpath)
if ncx <> "" then
dim as XmlTag navs()
dim as integer nn = xml_find_tags(ncx, "navPoint", navs())
for i as integer = 0 to nn - 1
dim as XmlTag lbls()
dim as integer nl = xml_find_tags(navs(i).inner, "text", lbls())
dim as XmlTag cts()
dim as integer nc = xml_find_tags(navs(i).inner, "content", cts())
if nl > 0 andalso nc > 0 then
if ntoc > ubound(ep.toc) then redim preserve ep.toc(0 to ntoc + MAX_TOC)
ep.toc(ntoc).label = xml_unescape(str_trim(lbls(0).inner))
' content src is relative to the NCX document, not to
' the OPF package; make it archive-rooted here.
ep.toc(ntoc).href = path_join(dir_of(ncxpath), url_decode(xml_attr(cts(0).raw, "src")))
ntoc += 1
end if
next
end if
end if
' Fall back to nav.xhtml anchors.
if ntoc = 0 andalso navpath <> "" then
dim as string nav = zip_extract_string(ep.za, navpath)
if nav <> "" then
dim as XmlTag as_()
dim as integer na = xml_find_tags(nav, "a", as_())
for i as integer = 0 to na - 1
dim as string hr = xml_attr(as_(i).raw, "href")
if hr <> "" then
if ntoc > ubound(ep.toc) then redim preserve ep.toc(0 to ntoc + MAX_TOC)
ep.toc(ntoc).label = xml_unescape(str_trim(as_(i).inner))
' nav.xhtml hrefs are relative to the nav document.
ep.toc(ntoc).href = path_join(dir_of(navpath), url_decode(hr))
ntoc += 1
end if
next
end if
end if
ep.ntoc = ntoc
return 0
end function
sub epub_close(byref ep as Epub)
zip_close(ep.za)
erase ep.spine
erase ep.toc
end sub
' =====================================================================
' Section 8: HTML to plain text
' =====================================================================
' Strip tags, decode entities, and insert newlines for block-level tags.
' Image alt text, if present, is emitted inline in brackets.
' On DOS, the output is translated from UTF-8 to CP437 at the end.
function html_to_text(h as string) as string
dim as string txt = ""
dim as integer i = 1
dim as integer n = len(h)
while i <= n
dim as integer lt = instr(i, h, "<")
if lt = 0 then
txt &= xml_unescape(mid(h, i))
exit while
end if
if lt > i then txt &= xml_unescape(mid(h, i, lt - i))
dim as integer gt = instr(lt, h, ">")
if gt = 0 then
txt &= xml_unescape(mid(h, lt))
exit while
end if
dim as string tag = str_lower(mid(h, lt + 1, gt - lt - 1))
dim as integer space_pos = instr(tag, " ")
dim as integer slash_pos = instr(tag, "/")
dim as integer cut = len(tag)
if space_pos > 0 andalso space_pos - 1 < cut then cut = space_pos - 1
if slash_pos > 0 andalso slash_pos - 1 < cut then cut = slash_pos - 1
if cut < 1 then cut = 1
dim as string nm = left(tag, cut)
if left(nm, 1) = "/" then nm = mid(nm, 2)
select case nm
case "br", "p", "div", "li", "h1", "h2", "h3", "h4", "h5", "h6", "tr", "hr", _
"blockquote", "pre", "figcaption", "figure", "article", "section", _
"header", "footer", "aside", "nav", "table", "ul", "ol", "dl", "dt", "dd"
txt &= chr(10)
case "img"
dim as string alttext = xml_attr(mid(h, lt + 1, gt - lt - 1), "alt")
alttext = xml_unescape(str_trim(alttext))
if alttext <> "" then
txt &= "[" & alttext & "]"
end if
end select
if nm = "script" or nm = "style" then
dim as integer cp = instr(gt, h, "</" & nm & ">")
if cp = 0 then cp = instr(gt, h, "</" & ucase(nm) & ">")
if cp > 0 then
i = cp + len(nm) + 3
else
' Unterminated block: drop everything to the end of the
' document rather than render the body as text.
i = n + 1
end if
continue while
end if
i = gt + 1
wend
' CRLF and lone CR bytes in the source would survive as chr(13) in
' the output and show up as control characters on screen; drop them.
if instr(txt, chr(13)) > 0 then
dim as string cleaned = ""
for k as integer = 1 to len(txt)
dim as integer cc = asc(txt, k)
if cc <> 13 then cleaned &= chr(cc)
next
txt = cleaned
end if
return cp437_fix(txt)
end function
' =====================================================================
' Section 9: Pager, word wrap, back-paging
' =====================================================================
' Page text is accumulated into a single big string (g_page_text) with
' an integer array of line-start offsets (g_page_off). This avoids the
' thousands of small string allocations that a string array would need
' on DOS, and makes page redraws cheap: slice on demand.
'
' Every wrapped line is retained, including lines before the resume
' point, so back-paging can walk into the skipped region.
dim shared as integer g_page_lines = DEFAULT_PAGE_LINES
dim shared as integer g_interactive = -1
dim shared as integer g_wrap_cols = DEFAULT_WRAP
dim shared as integer g_show_mode = 0
dim shared as string g_resume_file
dim shared as string g_resume_idx
dim shared as integer g_resume_toc = 0
dim shared as string g_page_text
dim shared as integer g_page_off(any)
dim shared as integer g_page_count = 0
dim shared as integer g_page_start = 0
' Append one wrapped line to the flat page buffer.
' Lines before the resume point are kept, not discarded.
sub buf_append(s as string)
if g_page_count > ubound(g_page_off) then
dim as integer newcap = iif(g_page_count = 0, 1024, g_page_count * 2)
redim preserve g_page_off(0 to newcap - 1)
end if
g_page_off(g_page_count) = len(g_page_text) + 1
g_page_text &= s & chr(10)
g_page_count += 1
end sub
' Return line i (0-based) from the flat buffer.
function page_line(i as integer) as string
if i < 0 or i >= g_page_count then return ""
dim as integer startpos = g_page_off(i)
dim as integer endpos
if i + 1 < g_page_count then
endpos = g_page_off(i+1) - 1
else
' The last line still carries the chr(10) appended by buf_append;
' exclude it so the final line does not print a spurious blank row.
endpos = len(g_page_text)
if endpos >= 1 andalso asc(g_page_text, endpos) = 10 then endpos -= 1
end if
if endpos < startpos then return ""
return mid(g_page_text, startpos, endpos - startpos)
end function
' Word-wrap a single source line and append each output line.
sub wrap_and_append(s as string)
' Empty and whitespace-only lines both produce one blank line, so a
' blank line that merely contains stray spaces does not silently
' merge the paragraphs on either side of it.
if str_trim(s) = "" then
buf_append ""
return
end if
dim as integer i = 1
dim as integer n = len(s)
dim as string curword = ""
dim as string curout = ""
while i <= n
dim as integer c = asc(s, i)
if c = 32 or c = 9 then
if curword <> "" then
if curout = "" then
curout = curword
elseif len(curout) + 1 + len(curword) <= g_wrap_cols then
curout = curout & " " & curword
else
buf_append curout
curout = curword
end if
curword = ""
end if
else
curword &= chr(c)
end if
i += 1
wend
if curword <> "" then
if curout = "" then
curout = curword
elseif len(curout) + 1 + len(curword) <= g_wrap_cols then
curout = curout & " " & curword
else
buf_append curout
curout = curword
end if
end if
if curout <> "" then buf_append curout
end sub
sub reset_pager()
g_page_text = ""
erase g_page_off
g_page_count = 0
g_page_start = 0
end sub
' Return the path of the bookmark file for a given epub.
' Strips the extension of the final path component, then appends
' ".bmk". So "book.epub" -> "book.bmk", and "dir/x.epub" -> "dir/x.bmk".
function bookmark_path(filename as string) as string
dim as integer last_slash = 0
for i as integer = len(filename) to 1 step -1
if mid(filename, i, 1) = "/" orelse mid(filename, i, 1) = "\" then
last_slash = i
exit for
end if
next
dim as integer last_dot = 0
for i as integer = len(filename) to last_slash + 1 step -1
if mid(filename, i, 1) = "." then
last_dot = i
exit for
end if
next
if last_dot = 0 then
return filename & ".bmk"
end if
return left(filename, last_dot - 1) & ".bmk"
end function
' Write the bookmark. Returns 0 on success, -1 on failure.
' Layout: line 1 = TOC index, line 2 = spine section, line 3 = skip.
function bookmark_write(filename as string, toc as integer, section as integer, skip as integer) as integer
dim as string bm = bookmark_path(filename)
dim as integer f = freefile
if open(bm for output as #f) <> 0 then return -1
print #f, str(toc)
print #f, str(section)
print #f, str(skip)
close #f
return 0
end function
' Read the bookmark. Returns 0 on success, -1 on failure.
' On success, sets toc, section, and skip.
function bookmark_read(filename as string, byref toc as integer, byref section as integer, byref skip as integer) as integer
dim as string bm = bookmark_path(filename)
dim as integer f = freefile
if open(bm for input as #f) <> 0 then return -1
dim as string line1, line2, line3
if eof(f) then
close #f
return -1
end if
line input #f, line1
if eof(f) then
close #f
return -1
end if
line input #f, line2
if eof(f) then
close #f
return -1
end if
line input #f, line3
close #f
toc = val(line1)
section = val(line2)
skip = val(line3)
if toc < 0 then toc = 0
if section < 0 then section = 0
if skip < 0 then skip = 0
return 0
end function
' Print the resume command for the current page top, and save the
' bookmark so a later `epubrd <file>` with no command resumes here.
' Only the bookmark form is printed. The explicit `show` form is
' not shown because, when the current TOC entry has an anchor,
' `show` would jump to the anchor and not to the exact line.
sub print_resume_hint()
if g_show_mode = 0 then return
dim as integer section = val(g_resume_idx)
dim as integer toc = g_resume_toc
if toc < 0 then toc = 0
print
print "to resume: epubrd " & g_resume_file
if section < 0 then
print "(no section was loaded; bookmark left unchanged)"
exit sub
end if
if bookmark_write(g_resume_file, toc, section, g_page_start) <> 0 then
print "(warning: could not write " & bookmark_path(g_resume_file) & ")"
end if
end sub
sub draw_page()
cls
dim as integer first = g_page_start
dim as integer last = g_page_start + g_page_lines - 1
if last > g_page_count - 1 then last = g_page_count - 1
for i as integer = first to last
print page_line(i)
next
end sub
' Extract one spine item to text and wrap it into the page buffer.
' Applies the given skip as g_page_start.
sub load_section(byref ep as Epub, idx as integer, skip as integer)
dim as string xhtml = zip_extract_string(ep.za, ep.spine(idx).href)
reset_pager()
if xhtml = "" then
buf_append "(could not read " & ep.spine(idx).href & ")"
return
end if
dim as string body = html_to_text(xhtml)
dim as integer i = 1
dim as integer bn = len(body)
dim as string curline = ""
while i <= bn
dim as integer c = asc(body, i)
if c = 10 then
wrap_and_append curline
curline = ""
else
curline &= chr(c)
end if
i += 1
wend
if curline <> "" then wrap_and_append curline
if skip > 0 then
g_page_start = skip
if g_page_start > g_page_count then g_page_start = g_page_count
end if
end sub
' Given a TOC index, resolve it to a spine index and a line skip.
' Returns 0 on success. Returns -1 if the TOC entry cannot be
' resolved to a spine item.
function resolve_toc_to_section(byref ep as Epub, tocidx as integer, byref section as integer, byref skip as integer) as integer
if tocidx < 0 or tocidx >= ep.ntoc then return -1
dim as string tpath = ""
dim as string tfrag = ""
split_href(ep.toc(tocidx).href, tpath, tfrag)
' TOC hrefs are made archive-rooted when the TOC is parsed, so they
' can be matched against spine hrefs directly. The opfdir join stays
' only as a fallback for books that root their TOC hrefs at the OPF
' package directory.
dim as integer idx = spine_index_by_href(ep, tpath)
if idx < 0 then
idx = spine_index_by_href(ep, path_join(ep.opfdir, tpath))
end if
if idx < 0 then return -1
section = idx
skip = 0
if tfrag <> "" then
dim as string xhtml = zip_extract_string(ep.za, ep.spine(idx).href)
if xhtml <> "" then
dim as integer off = find_anchor_offset(xhtml, tfrag)
if off > 0 then
dim as string prefix = html_to_text(left(xhtml, off - 1))
reset_pager()
dim as integer pi = 1
dim as string pcur = ""
while pi <= len(prefix)
dim as integer pc = asc(prefix, pi)
if pc = 10 then
wrap_and_append pcur
pcur = ""
else
pcur &= chr(pc)
end if
pi += 1
wend
if pcur <> "" then wrap_and_append pcur
skip = g_page_count
reset_pager()
end if
end if
end if
return 0
end function
' Draw the TOC screen and read a selection. Paginates if the TOC
' has more entries than fit on one screen. Returns the chosen TOC
' index, or -1 if the user cancelled with q/Q/Esc.
function show_toc_screen(byref ep as Epub) as integer
if ep.ntoc = 0 then
cls
print "Table of contents"
print
print "(no toc found)"
print
print "press any key to return"
getkey
return -1
end if
' Lines available for entries: page height minus two for the
' header and the prompt.
dim as integer per_page = g_page_lines - 2
if per_page < 4 then per_page = 4
dim as integer start_idx = 0
dim as string buf = ""
do
if start_idx < 0 then start_idx = 0
if start_idx > ep.ntoc - 1 then
start_idx = ep.ntoc - per_page
if start_idx < 0 then start_idx = 0
end if
cls
print "Table of contents"
print
dim as integer last_idx = start_idx + per_page - 1
if last_idx > ep.ntoc - 1 then last_idx = ep.ntoc - 1
for i as integer = start_idx to last_idx
dim as string label = ep.toc(i).label
if label = "" then label = ep.toc(i).href
print right(" " & str(i), 5) & " " & label
next
print
dim as integer more = 0
dim as integer prev = 0
if last_idx < ep.ntoc - 1 then more = 1
if start_idx > 0 then prev = 1
dim as string prompt = "[" & buf & "] "
if prev = 1 then prompt &= "b=back "
if more = 1 then prompt &= "space/Enter=more "
prompt &= "number then Enter=jump q/Q/Esc=return"
print prompt
dim as integer k = getkey
if k = KEY_CTRL_C then
print_resume_hint()
end 0
end if
if k = KEY_Q_LOWER or k = KEY_Q_UPPER or k = KEY_ESC then
return -1
end if
if k = KEY_B_LOWER or k = KEY_B_UPPER then
if prev = 1 then
start_idx -= per_page
if start_idx < 0 then start_idx = 0
end if
elseif k = KEY_ENTER then
if buf <> "" then
dim as integer chosen = val(buf)
if chosen >= 0 andalso chosen < ep.ntoc then
return chosen
end if
print chr(7);
buf = ""
else
if more = 1 then
start_idx = last_idx + 1
end if
end if
elseif k = KEY_SPACE then
if more = 1 then
start_idx = last_idx + 1
end if
elseif k >= 48 andalso k <= 57 then
buf &= chr(k)
else
' ignore unrecognized keys: no action
end if
loop
end function
sub page_loop(byref ep as Epub)
if g_interactive = 0 then
for i as integer = g_page_start to g_page_count - 1
print page_line(i)
next
return
end if
if g_page_count = 0 then return
do
draw_page()
dim as integer shown_last = g_page_start + g_page_lines - 1
if shown_last > g_page_count - 1 then shown_last = g_page_count - 1
print
print "-- [space]/[Enter]=next [b]=back [t]=toc [q]=quit --"
select case getkey
case KEY_CTRL_C, KEY_Q_LOWER, KEY_Q_UPPER
print_resume_hint()
end 0
case KEY_B_LOWER, KEY_B_UPPER
if g_page_start > 0 then
g_page_start -= g_page_lines
if g_page_start < 0 then g_page_start = 0
end if
case KEY_ENTER, KEY_SPACE
if shown_last >= g_page_count - 1 then
print_resume_hint()
end 0
end if
g_page_start = shown_last + 1
case KEY_T_LOWER, KEY_T_UPPER
dim as integer newsection = 0
dim as integer newskip = 0
dim as integer chosen = show_toc_screen(ep)
if chosen >= 0 then
if resolve_toc_to_section(ep, chosen, newsection, newskip) = 0 then
g_resume_toc = chosen
g_resume_idx = str(newsection)
load_section(ep, newsection, newskip)
else
print "cannot resolve toc entry"
sleep 1000
end if
end if
case else
' ignore all other keys
end select
loop
end sub
' =====================================================================
' Section 10: Main
' =====================================================================
sub show_usage()
print "epubrd"
print "usage:"
print " epubrd <file.epub> resume from bookmark"
print " epubrd <file.epub> meta"
print " epubrd <file.epub> meta_print"
print " epubrd <file.epub> toc"
print " epubrd <file.epub> toc_print"
print " epubrd <file.epub> show [n] [skip]"
print " epubrd <file.epub> goto_section <toc-index>"
print " epubrd <file.epub> next_section <n> [skip]"
print
print "toc opens the table of contents screen and lets you pick"
print "an entry to read. toc_print prints it as plain text, no"
print "interaction. `show`, `goto_section`, and `next_section`"
print "all take TOC indexes."
print
print "show [n] [skip]: read TOC entry n as text. If the entry"
print "has an anchor, jumps to it; otherwise starts at line 0 or"
print "at <skip> if given. With no argument, resumes from"
print "bookmark."
print
print "goto_section <toc-index>: same as `show <toc-index>`."
print
print "next_section <n> [skip]: read TOC entry n, then continue"
print "into n+1, n+2, and so on until the end of the TOC or quit."
print "skip applies to the first entry only if it has no anchor."
print
print "meta_print: print title and author as plain text, no"
print "interaction. Use this when you want to script or grep"
print "the metadata."
print
print "At each prompt:"
print " [space] or [Enter] next page"
print " [b] or [B] previous page"
print " [t] or [T] table of contents"
print " [q] or [Q] quit"
print " [ctrl-C] quit"
print " any other key ignored"
print "On quit, prints the command to resume at the top line on screen."
print
print "TOC screen:"
print " Type a number and press [Enter] to jump to that entry."
print " [space]/[Enter] pages forward; [b] pages back."
print " [q], [Q], or [Esc] returns to the page you were on."
print
print "Bookmark:"
print " On quit, writes <basename>.bmk next to the epub with the"
print " current TOC index, section, and line. `epubrd <file>` with"
print " no command resumes from it. For book.epub the bookmark is"
print " book.bmk."
print
print "Environment:"
print " EPUBREAD_MAX max epub size in bytes (default 16777216)"
print " EPUBREAD_COLS wrap width, 20..78 (default 78)"
print " EPUBREAD_ROWS terminal rows (default 25); lines/page = rows-4"
print " EPUBREAD_LINES content lines per page, overrides rows-4"
end sub
' Return argv[n], trimmed of trailing CR/LF/space that DOS may leave.
function clean_arg(n as integer) as string
dim as string s = command(n)
while len(s) > 0
dim as integer c = asc(s, len(s))
if c = 13 or c = 10 or c = 32 or c = 9 then
s = left(s, len(s) - 1)
else
exit while
end if
wend
return s
end function
sub read_environment()
#ifdef __FB_DOS__
g_interactive = -1
#else
g_interactive = iif(environ("TERM") <> "", -1, 0)
#endif
dim as string cols_env = environ("EPUBREAD_COLS")
if cols_env = "" then cols_env = environ("COLUMNS")
if cols_env <> "" then
dim as integer c = val(cols_env)
if c >= 20 andalso c <= DEFAULT_WRAP then g_wrap_cols = c
end if
dim as integer term_rows = DEFAULT_ROWS
dim as string rows_env = environ("EPUBREAD_ROWS")
if rows_env <> "" then
dim as integer r = val(rows_env)
if r >= 10 andalso r <= 250 then term_rows = r
end if
g_page_lines = term_rows - 4
if g_page_lines < 6 then g_page_lines = 6
dim as string lines_env = environ("EPUBREAD_LINES")
if lines_env <> "" then
dim as integer l = val(lines_env)
if l >= 6 andalso l <= 240 then g_page_lines = l
end if
end sub
read_environment()
dim as string filename = clean_arg(1)
dim as string cmd = clean_arg(2)
dim as string arg = clean_arg(3)
dim as string skip_arg = clean_arg(4)
if filename = "" then
show_usage()
end 1
end if
if cmd = "" then
dim as integer btoc = 0
dim as integer bsec = 0
dim as integer bskip = 0
if bookmark_read(filename, btoc, bsec, bskip) = 0 then
' Resume in spine terms, bypassing TOC resolution.
cmd = "resume"
arg = str(bsec)
skip_arg = str(bskip)
g_resume_toc = btoc
else
show_usage()
end 1
end if
end if
dim as Epub ep
if epub_load(ep, filename) <> 0 then
print "failed to open epub"
end 1
end if
' Clamp g_resume_toc to the TOC bounds, if the TOC has any entries.
' A bookmark written against a different epub, or a corrupt one,
' could give a TOC index that is out of range for this book. That
' only affects the display; it does not affect the resume position,
' which is keyed by spine section.
if ep.ntoc > 0 andalso g_resume_toc >= ep.ntoc then
g_resume_toc = 0
end if
select case cmd
case "meta"
reset_pager()
buf_append "title: " & ep.title
buf_append "author: " & ep.author
page_loop(ep)
case "meta_print"
print "title: " & ep.title
print "author: " & ep.author
epub_close(ep)
end 0
case "toc"
dim as integer chosen = show_toc_screen(ep)
if chosen < 0 then
epub_close(ep)
end 0
end if
dim as integer idx = 0
dim as integer skip = 0
if resolve_toc_to_section(ep, chosen, idx, skip) <> 0 then
print "cannot resolve toc entry " & str(chosen)
epub_close(ep)
end 1
end if
g_show_mode = 1
g_resume_file = filename
g_resume_toc = chosen
g_resume_idx = str(idx)
load_section(ep, idx, skip)
page_loop(ep)
case "toc_print"
if ep.ntoc = 0 then
print "(no toc found)"
else
for i as integer = 0 to ep.ntoc - 1
dim as string label = ep.toc(i).label
if label = "" then label = ep.toc(i).href
print str(i) & " " & label
next
end if
epub_close(ep)
end 0
case "goto_section"
dim as integer tocidx = val(arg)
if tocidx < 0 or tocidx >= ep.ntoc then
print "toc index out of range (see `toc`)"
epub_close(ep)
end 1
end if
dim as integer idx = 0
dim as integer skip = 0
if resolve_toc_to_section(ep, tocidx, idx, skip) <> 0 then
print "goto_section: no spine item matches " & ep.toc(tocidx).href
epub_close(ep)
end 1
end if
g_show_mode = 1
g_resume_file = filename
g_resume_toc = tocidx
g_resume_idx = str(idx)
load_section(ep, idx, skip)
page_loop(ep)
case "resume"
' Resume from bookmark, in spine terms. Used only by the
' no-command path, where the bookmark's second line is the
' spine section and the third is the skip.
dim as integer idx = val(arg)
dim as integer sk = 0
if skip_arg <> "" then
sk = val(skip_arg)
if sk < 0 then sk = 0
end if
if idx < 0 or idx >= ep.nspine then
print "resume: stored section out of range"
epub_close(ep)
end 1
end if
g_show_mode = 1
g_resume_file = filename
g_resume_idx = str(idx)
load_section(ep, idx, sk)
page_loop(ep)
case "show"
dim as integer idx = -1
dim as integer sk = 0
if arg = "" then
' Resume from bookmark directly, in spine terms.
dim as integer btoc = 0
dim as integer bsec = 0
dim as integer bskip = 0
if bookmark_read(filename, btoc, bsec, bskip) = 0 then
idx = bsec
sk = bskip
g_resume_toc = btoc
print "(resuming from bookmark: section " & str(idx) & _
", line " & str(sk) & ")"
else
print "show: no argument given and no bookmark found"
print "usage: epubrd <file.epub> show <toc-index> [skip]"
epub_close(ep)
end 1
end if
else
' Argument is a TOC index. Resolve to a section and an
' anchor-derived skip. If the entry has no anchor, use the
' caller's skip if given, otherwise 0.
dim as integer tocidx = val(arg)
if tocidx < 0 or tocidx >= ep.ntoc then
print "show: toc index out of range (see `toc`)"
epub_close(ep)
end 1
end if
dim as integer asec = 0
dim as integer askip = 0
if resolve_toc_to_section(ep, tocidx, asec, askip) <> 0 then
print "show: cannot resolve toc entry " & str(tocidx)
epub_close(ep)
end 1
end if
idx = asec
dim as string tpath = ""
dim as string tfrag = ""
split_href(ep.toc(tocidx).href, tpath, tfrag)
if tfrag <> "" then
' Anchor present: TOC wins, ignore caller's skip.
sk = askip
else
' No anchor: use caller's skip if given.
if skip_arg <> "" then
sk = val(skip_arg)
if sk < 0 then sk = 0
else
sk = 0
end if
end if
g_resume_toc = tocidx
end if
if idx < 0 or idx >= ep.nspine then
print "show: resolved section out of range"
epub_close(ep)
end 1
end if
g_show_mode = 1
g_resume_file = filename
g_resume_idx = str(idx)
load_section(ep, idx, sk)
page_loop(ep)
case "next_section"
dim as integer start_toc = val(arg)
if start_toc < 0 or start_toc >= ep.ntoc then
print "next_section: toc index out of range (see `toc`)"
epub_close(ep)
end 1
end if
g_show_mode = 1
g_resume_file = filename
' No spine section is known yet; the loop below fills this in with a
' real spine index before any page is shown. Keeping -1 here stops a
' TOC index from ever landing in the bookmark's spine-section field
' when nothing could be resolved at all.
g_resume_idx = "-1"
dim as integer first_sk = 0
if skip_arg <> "" then
first_sk = val(skip_arg)
if first_sk < 0 then first_sk = 0
end if
dim as integer cur_toc = start_toc
while cur_toc < ep.ntoc
g_resume_toc = cur_toc
' Resolve this TOC entry to a section and anchor skip.
dim as integer sec = 0
dim as integer askip = 0
if resolve_toc_to_section(ep, cur_toc, sec, askip) <> 0 then
cur_toc += 1
continue while
end if
dim as string tpath = ""
dim as string tfrag = ""
split_href(ep.toc(cur_toc).href, tpath, tfrag)
dim as integer sk = askip
if tfrag = "" andalso cur_toc = start_toc andalso first_sk > 0 then
sk = first_sk
end if
g_resume_idx = str(sec)
load_section(ep, sec, sk)
if g_page_count = 0 then
cur_toc += 1
continue while
end if
if g_interactive = 0 then
for i as integer = g_page_start to g_page_count - 1
print page_line(i)
next
cur_toc += 1
continue while
end if
dim as integer jump_toc = -1
dim as integer section_done = 0
while section_done = 0
draw_page()
dim as integer shown_last = g_page_start + g_page_lines - 1
if shown_last > g_page_count - 1 then shown_last = g_page_count - 1
print
print "-- [space]/[Enter]=next [b]=back [t]=toc [q]=quit --"
select case getkey
case KEY_CTRL_C, KEY_Q_LOWER, KEY_Q_UPPER
print_resume_hint()
end 0
case KEY_B_LOWER, KEY_B_UPPER
if g_page_start > 0 then
g_page_start -= g_page_lines
if g_page_start < 0 then g_page_start = 0
end if
case KEY_ENTER, KEY_SPACE
if shown_last >= g_page_count - 1 then
section_done = 1
else
g_page_start = shown_last + 1
end if
case KEY_T_LOWER, KEY_T_UPPER
dim as integer chosen = show_toc_screen(ep)
if chosen >= 0 then
jump_toc = chosen
section_done = 1
end if
case else
' ignore
end select
wend
if jump_toc >= 0 then
cur_toc = jump_toc
continue while
end if
cur_toc += 1
wend
print
print "(end of book)"
print_resume_hint()
case else
show_usage()
end select
epub_close(ep)
end 0
Comments