├── docs ├── README.md └── book-binding.md ├── images ├── logo.png ├── logo-192.png ├── logo-512.png └── logo.svg ├── examples ├── codedread.cbz ├── wizard-of-oz.epub └── alice-in-wonderland.epub ├── tools ├── modules │ ├── archives │ │ ├── go.mod │ │ └── extract.go │ ├── images │ │ ├── go.mod │ │ └── webp.go │ └── books │ │ ├── go.mod │ │ ├── metadata │ │ └── metadata.go │ │ ├── comic │ │ └── comic.go │ │ └── go.sum ├── README.md └── desadulate │ ├── go.mod │ ├── README.md │ ├── go.sum │ └── desadulate.go ├── .gitignore ├── .c8rc.json ├── code ├── bitjs │ ├── image │ │ ├── webp-shim │ │ │ ├── webp-shim-module.wasm │ │ │ └── webp-shim.js │ │ ├── parsers │ │ │ ├── README.md │ │ │ └── parsers.js │ │ └── metadata │ │ │ └── metadata.js │ ├── archive │ │ ├── webworker-wrapper.js │ │ ├── archive.js │ │ ├── common.js │ │ ├── events.js │ │ ├── untar.js │ │ └── compress.js │ ├── LICENSE │ ├── io │ │ ├── bytebuffer.js │ │ └── bitbuffer.js │ ├── index.js │ └── file │ │ └── sniffer.js ├── main.js ├── kthoom-microsoft.js ├── kthoom-messages.js ├── book-viewer-types.js ├── common │ ├── dom-walker.js │ └── helpers.js ├── config.js ├── file-ref.js ├── book-pump.js ├── kthoom-ipfs.js ├── comics │ ├── comic-book-page-sorter.js │ └── comic-book-binder.js ├── pages │ ├── page-setter.js │ ├── page-container.js │ ├── one-page-setter.js │ ├── two-page-setter.js │ ├── wide-strip-page-setter.js │ └── long-strip-page-setter.js ├── book-events.js ├── metadata │ ├── metadata-viewer.js │ └── book-metadata.js ├── book-binder.js └── kthoom-google.js ├── tests ├── comics │ ├── sortorder.tests.json │ └── sortorder.test.js ├── metadata │ └── book-metadata.test.js ├── common │ └── dom-walker.test.js ├── epub │ └── epub-allowlists.test.js └── pages │ ├── two-page-setter.test.js │ └── one-page-setter.test.js ├── kthoom.webmanifest ├── .github └── workflows │ ├── node.js.yml │ └── codeql-analysis.yml ├── LICENSE.txt ├── reading-lists ├── jrl-schema.json └── README.md ├── package.json ├── privacy.atom ├── privacy.html ├── service-worker.js └── README.md /docs/README.md: -------------------------------------------------------------------------------- 1 | TODO: API, architecture, sequence diagrams 2 | -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codedread/kthoom/HEAD/images/logo.png -------------------------------------------------------------------------------- /images/logo-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codedread/kthoom/HEAD/images/logo-192.png -------------------------------------------------------------------------------- /images/logo-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codedread/kthoom/HEAD/images/logo-512.png -------------------------------------------------------------------------------- /examples/codedread.cbz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codedread/kthoom/HEAD/examples/codedread.cbz -------------------------------------------------------------------------------- /examples/wizard-of-oz.epub: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codedread/kthoom/HEAD/examples/wizard-of-oz.epub -------------------------------------------------------------------------------- /tools/modules/archives/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/codedread/kthoom/tools/modules/archives 2 | 3 | go 1.16 4 | -------------------------------------------------------------------------------- /tools/modules/images/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/codedread/kthoom/tools/modules/images 2 | 3 | go 1.16 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | code/bitjs/.DS_Store 3 | coverage 4 | gkey.json 5 | node_modules 6 | tools/desadulate -------------------------------------------------------------------------------- /examples/alice-in-wonderland.epub: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codedread/kthoom/HEAD/examples/alice-in-wonderland.epub -------------------------------------------------------------------------------- /.c8rc.json: -------------------------------------------------------------------------------- 1 | { 2 | "all": true, 3 | "clean": true, 4 | "exclude": [ 5 | "code/bitjs", 6 | "tests" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /code/bitjs/image/webp-shim/webp-shim-module.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codedread/kthoom/HEAD/code/bitjs/image/webp-shim/webp-shim-module.wasm -------------------------------------------------------------------------------- /code/bitjs/image/parsers/README.md: -------------------------------------------------------------------------------- 1 | General-purpose, event-based parsers for digital images. 2 | 3 | Currently supports GIF, JPEG, and PNG. 4 | 5 | Some nice implementations of Exif parsing for HEIF, TIFF here: 6 | https://github.com/MikeKovarik/exifr/tree/master/src/file-parsers -------------------------------------------------------------------------------- /tools/modules/books/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/codedread/kthoom/tools/modules/books 2 | 3 | go 1.16 4 | 5 | replace github.com/codedread/kthoom/tools/modules/archives => ../archives 6 | 7 | require ( 8 | github.com/codedread/kthoom/tools/modules/archives v0.0.0-00010101000000-000000000000 9 | golang.org/x/net v0.38.0 10 | ) 11 | -------------------------------------------------------------------------------- /tools/README.md: -------------------------------------------------------------------------------- 1 | # Tools 2 | 3 | This a collection of golang code to help manage comic books. 4 | 5 | ## desadulate 6 | 7 | Take a comic book archive and make it less sad: 8 | 9 | * order pages in the archive by reading order 10 | * add/update ComicBook.xml to the archive and mark it optimized-for-streaming 11 | * optionally update all images to webp format (saves on space) 12 | -------------------------------------------------------------------------------- /code/main.js: -------------------------------------------------------------------------------- 1 | /* 2 | * main.js 3 | * 4 | * Licensed under the MIT License 5 | * 6 | * Copyright(c) 2020 Google Inc. 7 | */ 8 | 9 | import { config } from './config.js'; 10 | import { KthoomApp } from './kthoom.js'; 11 | 12 | config 13 | .set('PATH_TO_BITJS', './code/bitjs/') 14 | .lock(); 15 | 16 | const theApp = new KthoomApp(); 17 | if (!window.kthoom.getApp) { 18 | window.kthoom.getApp = () => theApp; 19 | } 20 | -------------------------------------------------------------------------------- /tests/comics/sortorder.tests.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "desc": "basic case", 4 | "input": ["bat2.jpg", "bat3.jpg", "bat1.jpg"], 5 | "expected": ["bat1.jpg", "bat2.jpg", "bat3.jpg"] 6 | }, 7 | { 8 | "desc": "case-insensitive", 9 | "input": ["BAT3.jpg", "bat1.jpg", "Bat2.jpg"], 10 | "expected": ["bat1.jpg", "Bat2.jpg", "BAT3.jpg"] 11 | }, 12 | { 13 | "desc": "forgot leading zeros", 14 | "input": ["bat10.jpg", "bat9.jpg", "bat8.jpg"], 15 | "expected": ["bat8.jpg", "bat9.jpg", "bat10.jpg"] 16 | } 17 | ] 18 | -------------------------------------------------------------------------------- /code/bitjs/image/parsers/parsers.js: -------------------------------------------------------------------------------- 1 | /* 2 | * parsers.js 3 | * 4 | * Common functionality for all image parsers. 5 | * 6 | * Licensed under the MIT License 7 | * 8 | * Copyright(c) 2024 Google Inc. 9 | */ 10 | 11 | /** 12 | * Creates a new event of the given type with the specified data. 13 | * @template T 14 | * @param {string} type The event type. 15 | * @param {T} data The event data. 16 | * @returns {CustomEvent} The new event. 17 | */ 18 | export function createEvent(type, data) { 19 | return new CustomEvent(type, { detail: data }); 20 | } 21 | -------------------------------------------------------------------------------- /tests/comics/sortorder.test.js: -------------------------------------------------------------------------------- 1 | import { Page } from '../../code/page.js'; 2 | import { sortPages } from '../../code/comics/comic-book-page-sorter.js'; 3 | 4 | import * as fs from 'fs'; 5 | import 'mocha'; 6 | import { expect } from 'chai'; 7 | 8 | describe('Sort order', () => { 9 | let testSpecs = JSON.parse(fs.readFileSync('./tests/comics/sortorder.tests.json').toString()); 10 | for (const spec of testSpecs) { 11 | it(spec.desc, () => { 12 | const pages = spec.input.map(name => new Page(name, 'image/jpeg')); 13 | const output = pages.sort(sortPages).map(page => page.getPageName()); 14 | expect(output).to.eql(spec.expected); 15 | }); 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /tools/desadulate/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/codedread/kthoom/tools/desadulate 2 | 3 | go 1.23.0 4 | 5 | replace ( 6 | github.com/codedread/kthoom/tools/modules/archives => ../modules/archives 7 | github.com/codedread/kthoom/tools/modules/books => ../modules/books 8 | github.com/codedread/kthoom/tools/modules/images => ../modules/images 9 | ) 10 | 11 | require ( 12 | github.com/codedread/kthoom/tools/modules/archives v0.0.0-20230209055651-aa4ca20b3dce 13 | github.com/codedread/kthoom/tools/modules/books v0.0.0-20230209055651-aa4ca20b3dce 14 | github.com/codedread/kthoom/tools/modules/images v0.0.0-20230209055651-aa4ca20b3dce 15 | ) 16 | 17 | require golang.org/x/net v0.38.0 // indirect 18 | -------------------------------------------------------------------------------- /tools/modules/books/metadata/metadata.go: -------------------------------------------------------------------------------- 1 | /** 2 | * books/metadata.go 3 | * 4 | * Licensed under the MIT License 5 | * 6 | * Copyright(c) 2021 Google Inc. 7 | */ 8 | 9 | // This package deals with metadata for books. 10 | package metadata 11 | 12 | import "encoding/xml" 13 | 14 | // Generic type for unmarshalling any XML node. 15 | type AnyNode struct { 16 | XMLName xml.Name 17 | Attrs []xml.Attr `xml:",any,attr"` 18 | Content string `xml:",innerxml"` 19 | } 20 | 21 | type ArchiveFileInfo struct { 22 | XMLName xml.Name `xml:"http://www.codedread.com/sop ArchiveFileInfo"` 23 | OptimizedForStreaming string `xml:"optimizedForStreaming,attr"` 24 | Attributes []xml.Attr `xml:",any,attr"` 25 | AnyNodes []AnyNode `xml:",any"` 26 | } 27 | -------------------------------------------------------------------------------- /kthoom.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Kthoom Comic Book Reader", 3 | "description": "Open source comic book reader built using web technologies.", 4 | "short_name": "kthoom", 5 | "icons": [ 6 | { 7 | "src": "images/logo-192.png", 8 | "sizes": "192x192", 9 | "type": "image/png" 10 | }, 11 | { 12 | "src": "images/logo.png", 13 | "sizes": "300x300", 14 | "type": "image/png" 15 | }, 16 | { 17 | "src": "images/logo-512.png", 18 | "sizes": "512x512", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "images/logo.svg", 23 | "sizes": "192x192 512x512 800x800 1000x1000", 24 | "type": "image/svg+xml" 25 | } 26 | ], 27 | "display": "standalone", 28 | "background_color": "#444", 29 | "theme_color": "#ffff00", 30 | "start_url": "/kthoom/index.html" 31 | } 32 | -------------------------------------------------------------------------------- /code/bitjs/archive/webworker-wrapper.js: -------------------------------------------------------------------------------- 1 | /** 2 | * webworker-wrapper.js 3 | * 4 | * Licensed under the MIT License 5 | * 6 | * Copyright(c) 2023 Google Inc. 7 | */ 8 | 9 | /** 10 | * A WebWorker wrapper for a decompress/compress implementation. Upon creation and being sent its 11 | * first message, it dynamically imports the decompressor / compressor implementation and connects 12 | * the message port. All other communication takes place over the MessageChannel. 13 | */ 14 | 15 | /** @type {MessagePort} */ 16 | let implPort; 17 | 18 | let module; 19 | 20 | onmessage = async (evt) => { 21 | if (evt.data.implSrc) { 22 | module = await import(evt.data.implSrc); 23 | module.connect(evt.ports[0]); 24 | // TODO: kthoom change for debugging. 25 | postMessage('connected'); 26 | } else if (evt.data.disconnect) { 27 | module.disconnect(); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /code/kthoom-microsoft.js: -------------------------------------------------------------------------------- 1 | /** 2 | * kthoom-microsoft.js 3 | * 4 | * Licensed under the MIT License 5 | * 6 | * Copyright(c) 2018 Google Inc. 7 | */ 8 | 9 | /** 10 | * Code for handling file access through Microsoft OneDrive. 11 | */ 12 | 13 | // http://msdn.microsoft.com/en-us/library/dn659750.aspx 14 | // http://msdn.microsoft.com/en-us/library/hh550837.aspx 15 | // WL.init() http://msdn.microsoft.com/en-us/library/hh550844.aspx 16 | // WL.login() http://msdn.microsoft.com/en-us/library/hh550845.aspx 17 | // Picker: http://msdn.microsoft.com/en-us/library/windows/apps/jj219328.aspx 18 | // and http://msdn.microsoft.com/en-us/library/windows/apps/jj219328.aspx#Display_the_file_picker__directly_by_calling_the_WL.fileDialog_method 19 | 20 | if (window.kthoom == undefined) { 21 | window.kthoom = {}; 22 | } 23 | 24 | kthoom.microsoft = { 25 | authed: false, 26 | }; 27 | -------------------------------------------------------------------------------- /docs/book-binding.md: -------------------------------------------------------------------------------- 1 | 2 | ## Creation 3 | 4 | ```mermaid 5 | sequenceDiagram 6 | actor User 7 | participant K as Kthoom 8 | participant B as Book 9 | participant BB as BookBinder 10 | participant UA as Unarchiver 11 | participant BV as BookViewer 12 | 13 | User->>K: loadLocalFile(f) 14 | K->>B: new Book(f) 15 | B->>BB: createBookBinder() 16 | BB->>UA: getUnarchiver() 17 | K->>BV: setCurrentBook() 18 | ``` 19 | 20 | ## Book Binding 21 | 22 | ```mermaid 23 | sequenceDiagram 24 | participant K as Kthoom 25 | participant B as Book 26 | participant BB as BookBinder 27 | participant UA as Unarchiver 28 | 29 | K->>B: appendBytes(bytes) 30 | B->>BB: appendBytes(byes) 31 | BB->>UA: update(bytes) 32 | BB->>UA: update(bytes) 33 | UA->>BB: Extract file 34 | Note right of BB: Page setting 35 | BB->>B: Extracted page 36 | ``` 37 | -------------------------------------------------------------------------------- /code/kthoom-messages.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file provides types for web app code wanting to orchestrate kthoom. It consists 3 | * of messages you can send to and receive from the Kthoom window. 4 | */ 5 | 6 | /** @enum */ 7 | export const MessageTypes = { 8 | LOAD_BOOKS: 'KthoomLoadBooks', 9 | } 10 | 11 | /** 12 | * @typedef BookFetchSpec A structure to hold book creation data via fetch. 13 | * @property {string} [body] The HTTP request body. Optional. 14 | * @property {string} method The HTTP request method. Required. Valid values are 'GET' and 'POST'. 15 | * @property {string} [name] The name of the book. Optional. If not present, the url is used. 16 | * @property {string} url The URL of the book for fetching. Required. 17 | */ 18 | 19 | /** 20 | * @typedef LoadBooksMessage Message sent from host to kthoom to load books. 21 | * @property {string} type Must be set to MessageTypes.LOAD_BOOKS. 22 | * @property {Array} bookFetchSpecs 23 | */ 24 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [16.x, 18.x, 20.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v2 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | - run: npm ci 29 | - run: npm run build --if-present 30 | - run: npm test 31 | -------------------------------------------------------------------------------- /tools/modules/images/webp.go: -------------------------------------------------------------------------------- 1 | /** 2 | * images/webp.go 3 | * 4 | * Licensed under the MIT License 5 | * 6 | * Copyright(c) 2021 Google Inc. 7 | */ 8 | 9 | // Package for dealing with images. 10 | package images 11 | 12 | import ( 13 | "fmt" 14 | "io" 15 | "os/exec" 16 | "path/filepath" 17 | "strings" 18 | ) 19 | 20 | func ConvertFileToWebp(imgFilename string, outWriter io.Writer, errWriter io.Writer) (string, error) { 21 | ext := strings.ToLower(filepath.Ext(imgFilename)) 22 | if ext != ".png" && ext != ".jpg" && ext != ".jpeg" { 23 | return "", fmt.Errorf("'%s' cannot be converted to Webp", imgFilename) 24 | } 25 | 26 | newImgFilename := imgFilename[0:len(imgFilename)-len(ext)] + ".webp" 27 | 28 | convertCmd := exec.Command("cwebp", "-quiet", imgFilename, "-o", newImgFilename) 29 | convertCmd.Stdout = outWriter 30 | convertCmd.Stderr = errWriter 31 | convertErr := convertCmd.Run() 32 | if convertErr != nil { 33 | return "", fmt.Errorf("cwebp finished with error: %v\n", convertErr) 34 | } 35 | 36 | return newImgFilename, nil 37 | } 38 | -------------------------------------------------------------------------------- /tests/metadata/book-metadata.test.js: -------------------------------------------------------------------------------- 1 | import { createMetadataFromComicBookXml, BookMetadata, ComicBookMetadataType } from '../../code/metadata/book-metadata.js'; 2 | 3 | import 'mocha'; 4 | import { expect } from 'chai'; 5 | 6 | describe('Book Metadata', () => { 7 | let m1, m2; 8 | 9 | beforeEach(() => { 10 | m1 = new BookMetadata(ComicBookMetadataType.COMIC_RACK, [ 11 | ['foo', 'abc'], 12 | ['bar', 'def'], 13 | ]); 14 | 15 | m2 = new BookMetadata(ComicBookMetadataType.COMIC_RACK, [ 16 | ['bar', 'def'], 17 | ['foo', 'abc'], 18 | ]); 19 | }); 20 | 21 | it('construction', () => { 22 | expect(m1.getBookType()).equals(ComicBookMetadataType.COMIC_RACK); 23 | expect(Array.from(m1.propertyEntries()).length).equals(2); 24 | }); 25 | 26 | it('equals() compares all fields', () => { 27 | expect(m1.equals(m2)).true; 28 | 29 | m2 = new BookMetadata(ComicBookMetadataType.COMIC_RACK, [ 30 | ['bar', 'def'], 31 | ['foo', 'abc'], 32 | ['baz', '123'], 33 | ]); 34 | 35 | expect(m1.equals(m2)).false; 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /code/bitjs/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2011 Google Inc, antimatter15 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /code/bitjs/archive/archive.js: -------------------------------------------------------------------------------- 1 | /** 2 | * archive.js 3 | * 4 | * Provides base functionality for unarchiving. 5 | * DEPRECATED: Use decompress.js instead. 6 | * 7 | * Licensed under the MIT License 8 | * 9 | * Copyright(c) 2011 Google Inc. 10 | */ 11 | 12 | // TODO(2.0): When up-revving to a major new version, remove this module. 13 | 14 | import { UnarchiveAppendEvent, UnarchiveErrorEvent, UnarchiveEvent, UnarchiveEventType, 15 | UnarchiveExtractEvent, UnarchiveFinishEvent, UnarchiveInfoEvent, 16 | UnarchiveProgressEvent, UnarchiveStartEvent } from './events.js'; 17 | import { Unarchiver, Unzipper, Unrarrer, Untarrer, getUnarchiver } from './decompress.js'; 18 | 19 | export { 20 | UnarchiveAppendEvent, 21 | UnarchiveErrorEvent, 22 | UnarchiveEvent, 23 | UnarchiveEventType, 24 | UnarchiveExtractEvent, 25 | UnarchiveFinishEvent, 26 | UnarchiveInfoEvent, 27 | UnarchiveProgressEvent, 28 | UnarchiveStartEvent, 29 | Unarchiver, 30 | Unzipper, Unrarrer, Untarrer, getUnarchiver 31 | } 32 | 33 | console.error(`bitjs: Stop importing archive.js, this module will be removed. Import decompress.js instead.`); 34 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2010 Jeff Schiller 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /code/book-viewer-types.js: -------------------------------------------------------------------------------- 1 | /** @enum */ 2 | export const FitMode = { 3 | Width: 1, 4 | Height: 2, 5 | Best: 3, 6 | } 7 | 8 | /** 9 | * @typedef Point Defines a pair of x/y values. 10 | * @property {number} x 11 | * @property {number} y 12 | */ 13 | 14 | /** 15 | * @typedef Box Defines a box/rectangle. 16 | * @property {number} left The left edge. 17 | * @property {number} top The top edge. 18 | * @property {number} width The width. 19 | * @property {number} height The height. 20 | */ 21 | 22 | // TODO: Add in a pageAspectRatios array for all the pages needing setting. 23 | /** 24 | * @typedef PageLayoutParams Configurable parameters for a page layout. 25 | * @property {number} rotateTimes The number of 90 degree clockwise rotations. 26 | * @property {FitMode} fitMode The fit mode. 27 | * @property {number} pageAspectRatio The aspect ratio of the pages in the book. 28 | * @property {Box} bv The BookViewer bounding box. 29 | */ 30 | 31 | /** 32 | * @typedef PageSetting A description of how pages and the book viewer should be layed out. 33 | * @property {Box[]} boxes Page bounding boxes. 34 | * @property {Box} bv The adjusted box for the book viewer. 35 | */ 36 | -------------------------------------------------------------------------------- /code/common/dom-walker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * dom-walker.js 3 | * 4 | * Licensed under the MIT License 5 | * 6 | * Copyright(c) 2019 Google Inc. 7 | */ 8 | 9 | export const NodeType = { 10 | ELEMENT: 1, 11 | ATTR: 2, 12 | TEXT: 3, 13 | }; 14 | 15 | /** 16 | * Do depth-first traversal of a DOM tree. 17 | * @param {Node} topNode The top node of a DOM tree. This node must not have a parentElement. 18 | * @param {Function(Element)} callbackFn 19 | */ 20 | export function walkDom(topNode, callbackFn) { 21 | if (!topNode || topNode.parentElement) { 22 | throw 'Top node in walkDom() must not have a parentElement'; 23 | } 24 | 25 | let curNode = topNode; 26 | while (curNode) { 27 | callbackFn(curNode); 28 | 29 | // First, try to go to first child. 30 | let nextNode = curNode.firstChild; 31 | // If that fails, try to go to next sibling. 32 | if (!nextNode) nextNode = curNode.nextSibling; 33 | // If that fails, keep trying to go to parent's next sibling, going up the tree, if needed. 34 | while (!nextNode && curNode.parentElement) { 35 | nextNode = curNode.parentElement.nextSibling; 36 | if (!nextNode) curNode = curNode.parentElement; 37 | } 38 | curNode = nextNode; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /reading-lists/jrl-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$id": "https://codedread.com/kthoom/reading-lists/jrl-schema.json", 4 | "type": "object", 5 | "properties": { 6 | "baseURI": { 7 | "description": "An optional absolute URI to use for URI references", 8 | "type": "string" 9 | }, 10 | "items": { 11 | "description": "The list of items in the reading list.", 12 | "type": "array", 13 | "items": { 14 | "type": "object", 15 | "properties": { 16 | "type": { 17 | "description": "The type of the item in the reading list. Only 'book' is supported", 18 | "type": "string", 19 | "pattern": "book" 20 | }, 21 | "uri": { 22 | "description": "The absolute or relative URI of the item in the reading list.", 23 | "type": "string", 24 | "format": "uri-reference" 25 | }, 26 | "name": { 27 | "description": "An optional readable name for the item in the reading list.", 28 | "type": "string" 29 | } 30 | }, 31 | "required": ["type", "uri"] 32 | } 33 | } 34 | }, 35 | "required": ["items"] 36 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kthoom", 3 | "version": "1.0.0", 4 | "description": "kthoom is a comic book / ebook reader that runs in the browser using client-side open web technologies such as JavaScript, HTML5, the File API, Web Workers, and Typed Arrays. It can open files from your local hard drive, the network, or Google Drive.", 5 | "type": "module", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/codedread/kthoom.git" 9 | }, 10 | "keywords": [ 11 | "comic books", 12 | "javascript", 13 | "books", 14 | "unzip", 15 | "ebook", 16 | "comics", 17 | "epub", 18 | "comic reader", 19 | "cbr", 20 | "ebook reader", 21 | "ebooks", 22 | "epub reader", 23 | "unrar", 24 | "cbz", 25 | "cbt" 26 | ], 27 | "author": "Jeff Schiller", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/codedread/kthoom/issues" 31 | }, 32 | "homepage": "https://github.com/codedread/kthoom#readme", 33 | "devDependencies": { 34 | "c8": "^8.0.1", 35 | "chai": "^4.3.10", 36 | "jsdom": "^22.1.0", 37 | "mocha": "^10.8.2" 38 | }, 39 | "scripts": { 40 | "coverage": "c8 npm run test", 41 | "test": "./node_modules/.bin/mocha tests --recursive" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /code/config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * config.js 3 | * 4 | * Licensed under the MIT License 5 | * 6 | * Copyright(c) 2020 Google Inc. 7 | */ 8 | 9 | let locked = false; 10 | const map = new Map(); 11 | 12 | class ConfigService { 13 | constructor() { } 14 | 15 | /** 16 | * @param {String} key 17 | * @returns {*} A copy of the value at that key. 18 | */ 19 | get(key) { 20 | if (!locked) throw 'Config was not locked. Did you forget to call lock()?'; 21 | if (typeof key !== 'string') throw 'key must be a string'; 22 | 23 | return map.get(JSON.parse(JSON.stringify(key))); 24 | } 25 | 26 | /** 27 | * @param {String} key 28 | * @param {*} val Any value that can be properly serialized to JSON. This value is round-tripped 29 | * through JSON.parse(JSON.stringify(val)) before stored. Do not attempt to store code. 30 | * @returns {ConfigService} Returns this for chaining. 31 | */ 32 | set(key, val) { 33 | if (locked) throw 'Config was already locked. Cannot set more values.'; 34 | if (typeof key !== 'string') throw 'key must be a string'; 35 | try { map.set(key, JSON.parse(JSON.stringify(val))); } 36 | catch (e) { throw `JSON.parse error: ${e}`; } 37 | return this; 38 | } 39 | 40 | lock() { 41 | if (locked) throw 'Config was already locked.'; 42 | locked = true; 43 | } 44 | } 45 | 46 | export const config = new ConfigService(); 47 | -------------------------------------------------------------------------------- /reading-lists/README.md: -------------------------------------------------------------------------------- 1 | # JSON Reading List (JRL) Files 2 | 3 | kthoom supports loading in Reading Lists of books. Think of a reading list as a playlist for your comic book reader. It's a way to load in a bunch of comic book files at once. 4 | 5 | Since I could not find any existing format for this, I created my own: JSON Reading List files (.JRL). 6 | 7 | The format is simple: 8 | 9 | ```json 10 | { 11 | "baseURI": "https://example.com", 12 | "items": [ 13 | {"type": "book", "uri": "/foo/bar.cbz", "name": "Optional name"}, 14 | {"type": "book", "uri": "http://example.com/foo/baz.cbr"} 15 | ] 16 | } 17 | ``` 18 | 19 | * The "baseURI" field is optional. If present, it is used to resolve item URI references. 20 | * The "uri" field must be an absolute URI or a URI reference that points to a comic book file (.cbz, .cbr). If it is a URI reference: 21 | * if baseURI is present, that is used 22 | * else if the Reading List file was fetched via a URI, the Reading List file's URI base is used 23 | * otherwise, behavior is undefined. 24 | * The "type" field must have the value "book". 25 | * The "name" field is optional and can be a short name for the comic book. 26 | 27 | The JSON schema for the JRL file format is [here](https://codedread.github.io/kthoom/reading-lists/jrl-schema.json). 28 | 29 | I created a simple web app to let you search for books and create reading lists: [jrlgen](https://github.com/codedread/jrlgen). 30 | -------------------------------------------------------------------------------- /privacy.atom: -------------------------------------------------------------------------------- 1 | 2 | 3 | 2021-07-05T:18:30-08:00 4 | kthoom Privacy Updates Feed 5 | https://www.codedread.com/kthoom 6 | 7 | 8 | 9 | Jeff Schiller 10 | https://www.codedread.com/kthoom 11 | 12 | Copyright (c) 2021 Jeff Schiller 13 | 14 | 15 | Jeff Schiller 16 | 17 | Introduced the Privacy Policy changes feed 18 | https://www.codedread.com/kthoom/privacy/1 19 | 2021-07-05T:18:30-08:00 20 | 21 |
22 |

Added a feed to follow any changes to the 23 | Privacy Policy 24 | for the kthoom comic book reader. 25 | Added a link to this Atom feed in the privacy policy and clarified that Google Analytics 26 | is used to track basic app behavior only.

27 |
28 |
29 |
30 | 31 |
-------------------------------------------------------------------------------- /code/file-ref.js: -------------------------------------------------------------------------------- 1 | /** 2 | * file-ref.js 3 | * 4 | * Licensed under the MIT License 5 | * 6 | * Copyright(c) 2019 Google Inc. 7 | */ 8 | 9 | /** 10 | * A reference to a file in an archive. 11 | */ 12 | export class FileRef { 13 | /** 14 | * @param {string} id 15 | * @param {string} href 16 | * @param {string} rootDir 17 | * @param {string} mediaType 18 | * @param {Uint8Array} data 19 | */ 20 | constructor(id, href, rootDir, mediaType, data) { 21 | /** @type {string} */ 22 | this.id = id; 23 | 24 | /** @type {string} */ 25 | this.href = href; 26 | 27 | /** @type {string} */ 28 | this.rootDir = rootDir; 29 | 30 | /** @type {string} */ 31 | this.mediaType = mediaType; 32 | 33 | /** @type {Uint8Array} */ 34 | this.data = data; 35 | 36 | /** @private {Blob} */ 37 | this.blob_ = undefined; 38 | 39 | /** @private {string} */ 40 | this.blobURL_ = undefined; 41 | } 42 | 43 | /** 44 | * @param {Window} win 45 | * @returns {Blob} 46 | */ 47 | getBlob(win) { 48 | this.initializeBlob_(win); 49 | return this.blob; 50 | } 51 | 52 | /** 53 | * @param {Window} win 54 | * @returns {string} 55 | */ 56 | getBlobURL(win) { 57 | this.initializeBlob_(win); 58 | return this.blobURL; 59 | } 60 | 61 | /** 62 | * @param {Window} win 63 | * @private 64 | */ 65 | initializeBlob_(win) { 66 | this.blob = new win.Blob([this.data], {type: this.mediaType}); 67 | this.blobURL = win.URL.createObjectURL(this.blob); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /code/bitjs/image/metadata/metadata.js: -------------------------------------------------------------------------------- 1 | /* 2 | * metadata.js 3 | * 4 | * Reads metadata from images. 5 | * 6 | * Licensed under the MIT License 7 | * 8 | * Copyright(c) 2023 Google Inc. 9 | */ 10 | 11 | // Metadata storage of interest: XMP, Exif, IPTC. 12 | 13 | import * as fs from 'node:fs'; 14 | import { findMimeType } from '../../file/sniffer.js'; 15 | import { GifParser } from '../parsers/gif.js'; 16 | 17 | const FILENAME = 'tests/image-testfiles/xmp.gif'; 18 | 19 | /** 20 | * @param {ArrayBuffer} ab 21 | * @returns {Promise} A Promise that resolves when parsing is done. 22 | */ 23 | export async function getImageMetadata(ab) { 24 | const mimeType = findMimeType(ab); 25 | switch (mimeType) { 26 | case 'image/gif': 27 | const gifParser = new GifParser(ab); 28 | gifParser.addEventListener('application_extension', evt => { 29 | const ext = evt.applicationExtension; 30 | if (ext.applicationIdentifier === 'XMP Data') { 31 | const authCode = new TextDecoder().decode(ext.applicationAuthenticationCode); 32 | if (authCode === 'XMP') { 33 | // TODO: Parse this. 34 | console.dir(new TextDecoder().decode(ext.applicationData)); 35 | } 36 | } 37 | }); 38 | 39 | await gifParser.start(); 40 | 41 | break; 42 | default: 43 | throw `Unsupported image type: ${mimeType}`; 44 | } 45 | 46 | return null; 47 | } 48 | 49 | function main() { 50 | const nodeBuf = fs.readFileSync(FILENAME); 51 | const fileData = new Uint8Array( 52 | nodeBuf.buffer.slice(nodeBuf.byteOffset, nodeBuf.byteOffset + nodeBuf.length)); 53 | getImageMetadata(fileData.buffer); 54 | } 55 | 56 | main(); 57 | -------------------------------------------------------------------------------- /code/book-pump.js: -------------------------------------------------------------------------------- 1 | /** 2 | * book-pump.js 3 | * Licensed under the MIT License 4 | * Copyright(c) 2020 Google Inc. 5 | */ 6 | 7 | /** 8 | * @type {Object} 9 | * @enum 10 | */ 11 | export const BookPumpEventType = { 12 | BOOKPUMP_DATA_RECEIVED: 'BOOKPUMP_DATA_RECEIVED', 13 | BOOKPUMP_END: 'BOOKPUMP_END', 14 | BOOKPUMP_ERROR: 'BOOKPUMP_ERROR', 15 | }; 16 | 17 | class BookPumpEvent extends Event { 18 | constructor(type) { super(type); } 19 | } 20 | 21 | /** 22 | * This is a simple class that receives book data from an outside source and then emits Events 23 | * that a Book can subscribe to for creation/loading. Use this when you are receiving data and 24 | * need fine control over how the data is "pumped" to kthoom. A good example is when you are 25 | * streaming book bytes from a Cloud API and want to send each chunk on to kthoom as you get it. 26 | */ 27 | export class BookPump extends EventTarget { 28 | constructor() { super(); } 29 | 30 | /** 31 | * Call this method when you are ready to send the next set of book data (bytes) to kthoom. 32 | * This method must be called in the correct order that the bytes should be concatenated. 33 | * @param {ArrayBuffer} ab 34 | * @param {number} totalExpectedSize 35 | */ 36 | onData(ab, totalExpectedSize) { 37 | const evt = new BookPumpEvent(BookPumpEventType.BOOKPUMP_DATA_RECEIVED); 38 | evt.ab = ab; 39 | evt.totalExpectedSize = totalExpectedSize; 40 | this.dispatchEvent(evt); 41 | } 42 | 43 | /** Call this if you have encountered an error. */ 44 | onError(err) { 45 | const evt = new BookPumpEvent(BookPumpEventType.BOOKPUMP_ERROR); 46 | evt.err = err; 47 | this.dispatchEvent(evt); 48 | } 49 | 50 | /** Call this method when you have received all the bytes for a book. */ 51 | onEnd() { this.notify(new BookPumpEvent(BookPumpEventType.BOOKPUMP_END)); } 52 | } 53 | -------------------------------------------------------------------------------- /tools/desadulate/README.md: -------------------------------------------------------------------------------- 1 | # Desadulate 2 | 3 | A program that builds a better comic book archive file by ensuring the files inside 4 | the zip are in order so that the archive can be streamed and unopened on the fly. 5 | 6 | ## Getting Started 7 | 8 | * Install golang 9 | * Install cwebp (if you want to convert images to webp format) 10 | * ```cd tools/desadulate``` 11 | * ```go build``` 12 | 13 | ## Command-line arguments 14 | 15 | ### Required arguments 16 | | Argument | Description | 17 | | ---------------------- | ------------------------------------------------------------------------- | 18 | | -i /old/path/to/comics | The input path where the comic book archive files are located. | 19 | | -o /new/path/to/comics | The output path where the new comic book archive files should be created. | 20 | | -f sub/path/comic.cbz | The relative path inside of the input path pointing to the comic book. | 21 | 22 | ### Optional arguments 23 | | Argument | Description | 24 | | ---------------------- | ------------------------------------------------------------------------- | 25 | | -webp | Convert all images in the archive to the WebP format. | 26 | 27 | ## Running desadulate 28 | 29 | To convert /old/path/to/comics/foo/bar/book.cbz into /new/path/to/comics/foo/bar/book.cbz: 30 | 31 | ```bash 32 | desadulate -i /old/path/to/comics -o /new/path/to/comics -f foo/bar/book.cbz 33 | ``` 34 | 35 | To convert all comic books in /old/path/to/comics/ and put into /new/path/to/comics/ (assumes globstar or zsh): 36 | 37 | ```zsh 38 | for F in /old/path/to/comics/**/*.cb? 39 | do 40 | B=`echo $F | cut -c19-`; 41 | desadulate -i /old/path/to/comics -o /new/opath/to/comics -f "$B"; 42 | done 43 | ``` 44 | -------------------------------------------------------------------------------- /code/kthoom-ipfs.js: -------------------------------------------------------------------------------- 1 | /** 2 | * kthoom-ipfs.js 3 | * 4 | * Licensed under the MIT License 5 | * 6 | * Copyright(c) 2018 Google Inc. 7 | */ 8 | 9 | /** 10 | * Code for handling file access through IPFS. 11 | */ 12 | 13 | if (window.kthoom === undefined) { 14 | window.kthoom = {}; 15 | } 16 | 17 | kthoom.ipfs = { 18 | nodePromise_: undefined, 19 | node_: undefined, 20 | getNode() { 21 | if (!kthoom.ipfs.nodePromise_) { 22 | kthoom.getApp().updateProgressMeter('Loading code for IPFS...'); 23 | kthoom.ipfs.nodePromise_ = new Promise((resolve, reject) => { 24 | // Load in the IPFS script API. 25 | const ipfsScriptEl = document.createElement('script'); 26 | ipfsScriptEl.addEventListener('load', () => { 27 | if (window.ipfs) { 28 | kthoom.ipfs.node_ = window.ipfs; 29 | resolve(window.ipfs); 30 | } else { 31 | kthoom.getApp().updateProgressMeter('Creating IPFS node...'); 32 | const node = window.Ipfs.createNode(); 33 | node.on('start', () => { 34 | kthoom.ipfs.node_ = node; 35 | resolve(node); 36 | }); 37 | } 38 | }); 39 | ipfsScriptEl.setAttribute('src', 'https://unpkg.com/ipfs@0.27.7/dist/index.js'); 40 | document.body.appendChild(ipfsScriptEl); 41 | }); 42 | } 43 | return kthoom.ipfs.nodePromise_; 44 | }, 45 | loadHash(ipfshash) { 46 | kthoom.ipfs.getNode().then(node => { 47 | kthoom.getApp().updateProgressMeter('Fetching data from IPFS...'); 48 | node.files.cat(ipfshash, (err, data) => { 49 | if (err) throw err; 50 | 51 | // NOTE: The API says this will be a Buffer, but I'm seeing an Uint8Array. 52 | if (data instanceof Uint8Array) { 53 | kthoom.getApp().loadSingleBookFromArrayBuffer(ipfshash, data.buffer); 54 | } 55 | }); 56 | }); 57 | }, 58 | ipfsHashWindow() { 59 | const ipfshash = window.prompt("Enter the IPFS hash of the book to load"); 60 | if (ipfshash) { 61 | kthoom.ipfs.loadHash(ipfshash); 62 | } 63 | }, 64 | }; 65 | -------------------------------------------------------------------------------- /code/comics/comic-book-page-sorter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * One of the worst things about the Comic Book Archive format is that it is de facto. 3 | * Most definitions say the sort order is supposed to be lexically sorted filenames. 4 | * However, some comic books, and therefore some reader apps, do not follow this rule. 5 | * We will carefully add special cases here as we find them in the wild. We may not be 6 | * able to handle every case; some books are just broken. 7 | * @param {Page} a 8 | * @param {Page} b 9 | * @returns 10 | */ 11 | export function sortPages(a, b) { 12 | // ===================================================================================== 13 | // Special Case 1: Files are incorrectly named foo8.jpg, foo9.jpg, foo10.jpg. 14 | // This causes foo10.jpg to sort before foo8.jpg when listing alphabetically. 15 | 16 | // Strip off file extension. 17 | const aName = a.getPageName().replace(/\.[^/.]+$/, ''); 18 | const bName = b.getPageName().replace(/\.[^/.]+$/, ''); 19 | 20 | // If we found numbers at the end of the filenames ... 21 | const aMatch = aName.match(/(\d+)$/g); 22 | const bMatch = bName.match(/(\d+)$/g); 23 | if (aMatch && aMatch.length === 1 && bMatch && bMatch.length === 1) { 24 | // ... and the prefixes case-insensitive match ... 25 | const aPrefix = aName.substring(0, aName.length - aMatch[0].length); 26 | const bPrefix = aName.substring(0, bName.length - bMatch[0].length); 27 | if (aPrefix.toLowerCase() === bPrefix.toLowerCase()) { 28 | // ... then numerically evaluate the numbers for sorting purposes. 29 | return parseInt(aMatch[0], 10) > parseInt(bMatch[0], 10) ? 1 : -1; 30 | } 31 | } 32 | 33 | // Special Case 2? I've seen this one a couple times: 34 | // RobinHood12-02.jpg, RobinHood12-03.jpg, robinhood12-01.jpg, robinhood12-04.jpg. 35 | // If a common prefix is used, and we find a file that has the same common prefix 36 | // but not the right case, then case-insensitive lexical sort? 37 | 38 | // ===================================================================================== 39 | 40 | // Default is case-sensitive lexical/alphabetical sort. 41 | return a.getPageName() > b.getPageName() ? 1 : -1; 42 | } 43 | -------------------------------------------------------------------------------- /code/pages/page-setter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A thing that sets up page containers in the UI. 3 | */ 4 | 5 | /** @typedef {import('../book-viewer-types.js').Box} Box */ 6 | /** @typedef {import('../book-viewer-types.js').Point} Point */ 7 | /** @typedef {import('../book-viewer-types.js').PageLayoutParams} PageLayoutParams */ 8 | /** @typedef {import('../book-viewer-types.js').PageSetting} PageSetting */ 9 | 10 | // TODO: topw and toph need to be returned from updateLayout() as well. 11 | 12 | /** 13 | * The job of a PageSetter is to tell the BookViewer how many pages to render and their 14 | * dimensions. Override the updateLayout() method. 15 | * @abstract 16 | */ 17 | export class PageSetter { 18 | /** 19 | * Get the scroll delta for the book viewer by a give # of pages. 20 | * @param {number} numPages The number of pages to scroll. 21 | * @param {Box} pageContainerBox The box of the page container. 22 | * @param {number} rotateTimes The # of clockwise 90-degree rotations. 23 | * @returns {Point} The number of pixels to scroll in x,y directions. 24 | */ 25 | getScrollDelta(numPages, pageContainerBox, rotateTimes) { 26 | // Only useful for the Long-Strip or Wide-Strip setters. 27 | return { x: 0, y: 0 }; 28 | } 29 | 30 | /** 31 | * @param {number} docScrollLeft The x-scroll position of the document. 32 | * @param {number} docScrollTop The y-scroll position of the document. 33 | * @param {Box[]} pageBoxes The dimensions of all visible PageContainers. 34 | * @param {number} rotateTimes The # of clockwise 90-degree rotations. 35 | * @returns {number} How far the viewer is scrolled into the book. 36 | */ 37 | getScrollPosition(docScrollLeft, docScrollTop, pageBoxes, rotateTimes) { 38 | return 0; 39 | } 40 | 41 | /** 42 | * The job of this function is to lay out the dimensions of all the page boxes that the 43 | * BookViewer needs to render. It returns an array of page container frames that the 44 | * BookViewer will fill as well as the adjusted bookViewer box. 45 | * @abstract 46 | * @param {PageLayoutParams} layoutParams 47 | * @param {Box} bv The BookViewer bounding box. 48 | * @returns {PageSetting} A set of Page bounding boxes. 49 | */ 50 | updateLayout(layoutParams, bv) { 51 | throw 'Unimplemented PageSetter error!'; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /code/book-events.js: -------------------------------------------------------------------------------- 1 | /** 2 | * book-events.js 3 | * Licensed under the MIT License 4 | * Copyright(c) 2019 Google Inc. 5 | */ 6 | 7 | /** 8 | * @type {Object} 9 | * @enum 10 | */ 11 | export const BookEventType = { 12 | UNKNOWN: 'BOOK_EVENT_UNKNOWN', 13 | BINDING_COMPLETE: 'BOOK_EVENT_BINDING_COMPLETE', 14 | LOADING_STARTED: 'BOOK_EVENT_LOADING_STARTED', 15 | LOADING_COMPLETE: 'BOOK_EVENT_LOADING_COMPLETE', 16 | METADATA_XML_EXTRACTED: 'BOOK_EVENT_METADATA_XML_EXTRACTED', 17 | PAGE_EXTRACTED: 'BOOK_EVENT_PAGE_EXTRACTED', 18 | PROGRESS: 'BOOK_EVENT_PROGRESS', 19 | UNARCHIVE_COMPLETE: 'BOOK_EVENT_UNARCHIVE_COMPLETE', 20 | }; 21 | 22 | // For node environments, window.Event does not exist. 23 | let Event = Object; 24 | try { Event = window.Event } catch(e) {} 25 | 26 | /** 27 | * The source can be a Book. Can also be a BookBinder internal to Book. 28 | */ 29 | export class BookEvent extends Event { 30 | constructor(source, type = BookEventType.UNKNOWN) { 31 | super(type); 32 | /** @type {Book|BookBinder} */ 33 | this.source = source; 34 | } 35 | } 36 | 37 | export class BookLoadingStartedEvent extends BookEvent { 38 | constructor(source) { 39 | super(source, BookEventType.LOADING_STARTED); 40 | } 41 | } 42 | 43 | export class BookLoadingCompleteEvent extends BookEvent { 44 | constructor(source) { 45 | super(source, BookEventType.LOADING_COMPLETE); 46 | } 47 | } 48 | 49 | export class BookMetadataXmlExtractedEvent extends BookEvent { 50 | constructor(source, bookMetadata) { 51 | super(source, BookEventType.METADATA_XML_EXTRACTED); 52 | this.bookMetadata = bookMetadata; 53 | } 54 | } 55 | 56 | export class BookPageExtractedEvent extends BookEvent { 57 | constructor(source, page, pageNum) { 58 | super(source, BookEventType.PAGE_EXTRACTED); 59 | this.page = page; 60 | this.pageNum = pageNum; 61 | } 62 | } 63 | 64 | export class BookProgressEvent extends BookEvent { 65 | constructor(source, totalPages = undefined, message = undefined) { 66 | super(source, BookEventType.PROGRESS); 67 | this.totalPages = totalPages; 68 | this.message = message; 69 | } 70 | } 71 | 72 | export class BookBindingCompleteEvent extends BookEvent { 73 | constructor(source) { 74 | super(source, BookEventType.BINDING_COMPLETE); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /privacy.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | kthoom Privacy Policy 5 | 6 | 7 |

Privacy Policy

8 | 9 |

10 | kthoom is an open source project. There is no legal entity (company) 11 | associated with this project, only developers who donate their time. 12 |

13 | 14 |

15 | This project does not collect any information about our users or anything they read 16 | with kthoom. Since we do not track our users, to follow any privacy changes to kthoom 17 | you can follow this Atom feed 18 | for changes. Basic app activity is tracked as noted below via Google Analytics. 19 |

20 | 21 |

22 | This project does not use browser cookies. It uses Browser Local Storage to Save 23 | your settings. 24 |

25 | 26 |

27 | kthoom consists solely of client-side code (a HTML page, its stylesheet, 28 | and associated JavaScript files). kthoom does not talk to a server except 29 | as described below: 30 |

31 | 32 |
    33 |
  1. 34 | When you are using kthoom on this site, 35 | the developer is using 36 | Google Analytics to track basic website activity, including URLs and 37 | URL parameters that are being used with the project. This project does 38 | not pass any data to Google that can be used to personally identify the 39 | user. 40 |
  2. 41 | 42 |
  3. 43 | If you want to read comic books from 44 | Google Drive, this project 45 | will require you to sign into Google and kthoom will let you choose a 46 | file from your Google Drive. 47 |
  4. 48 | 49 |
  5. 50 | If you want to read comic books from IPFS, 51 | kthoom will connect to IPFS nodes to download the book file. 52 |
  6. 53 |
54 | 55 |

Client-only Mode

56 |

57 | If you are uncomfortable with kthoom using Google Analytics or accessing 58 | servers over the network, it is totally within your rights to clone the 59 | github repository, 60 | remove the GA code from the HTML, and then run a local web server to host 61 | your own copy to read comic books on your local hard drive. 62 |

63 | 64 | -------------------------------------------------------------------------------- /code/common/helpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * helpers.js 3 | * 4 | * Licensed under the MIT License 5 | * 6 | * Copyright(c) 2018 Google Inc. 7 | */ 8 | 9 | /** @enum */ 10 | export const Key = { 11 | TAB: 9, 12 | ENTER: 13, 13 | ESCAPE: 27, 14 | LEFT: 37, 15 | UP: 38, 16 | RIGHT: 39, 17 | DOWN: 40, 18 | NUM_0: 48, NUM_1: 49, NUM_2: 50, NUM_3: 51, NUM_4: 52, 19 | NUM_5: 53, NUM_6: 54, NUM_7: 55, NUM_8: 56, NUM_9: 57, 20 | A: 65, B: 66, C: 67, D: 68, E: 69, F: 70, G: 71, H: 72, I: 73, J: 74, K: 75, L: 76, M: 77, 21 | N: 78, O: 79, P: 80, Q: 81, R: 82, S: 83, T: 84, U: 85, V: 86, W: 87, X: 88, Y: 89, Z: 90, 22 | QUESTION_MARK: 191, 23 | LEFT_SQUARE_BRACKET: 219, 24 | RIGHT_SQUARE_BRACKET: 221, 25 | }; 26 | 27 | // Get the document (or an empty object in the case of this module being pulled into tests). 28 | let document = {}; 29 | try { document = window.document; } catch (e) {}; 30 | 31 | /** 32 | * @param {string} id The id of the element to get. 33 | * @returns {HTMLElement} The element. 34 | */ 35 | export function getElem(id) { 36 | return document.body.querySelector('#' + id); 37 | } 38 | 39 | // Parse the URL parameters the first time this module is loaded. 40 | export const Params = {}; 41 | const search = document?.location?.search; 42 | if (search && search[0] === '?') { 43 | const args = search.substring(1).split('&'); 44 | for (let arg of args) { 45 | const kv = arg.split('='); 46 | if (kv.length == 2) { 47 | const key = decodeURIComponent(kv[0]); 48 | const val = decodeURIComponent(kv[1]); 49 | Params[key] = val; 50 | } 51 | } 52 | } 53 | 54 | /** 55 | * Takes Params and updates the browser URL 56 | */ 57 | export function serializeParamsToBrowser() { 58 | let paramStr = ''; 59 | let separator = ''; 60 | for (const [key, value] of Object.entries(Params)) { 61 | paramStr += `${separator}${key}=${value}`; 62 | separator = '&'; 63 | } 64 | let fullUri = document.location.pathname + '?' + paramStr; 65 | history.replaceState(null, '', fullUri); 66 | } 67 | 68 | /** 69 | * If ?debug=true, then throws a JavaScript error, otherwise prints a console.error(). If 70 | * optContextObj is set, also console.dirs it. 71 | * @param {boolean} cond 72 | * @param {string=} str 73 | * @param {Object=} optContextObj 74 | */ 75 | export function assert(cond, str = 'Unknown error', optContextObj = undefined) { 76 | if (!cond) { 77 | if (Params.debug) { 78 | throw str; 79 | } else { 80 | console.error(str); 81 | if (optContextObj) { 82 | console.dir(optContextObj); 83 | } 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tests/common/dom-walker.test.js: -------------------------------------------------------------------------------- 1 | import 'mocha'; 2 | import { expect } from 'chai'; 3 | import { JSDOM } from 'jsdom'; 4 | import { NodeType, walkDom } from '../../code/common/dom-walker.js'; 5 | 6 | describe('dom-walker', () => { 7 | describe('walkDom()', () => { 8 | let doc; 9 | beforeEach(() => { 10 | const dom = new JSDOM('' 11 | + '\n' 12 | + 'Title' 13 | + '' 14 | + '\n' 15 | + '

hello

' 16 | + '

Goodbye

' 17 | + '' 18 | + ''); 19 | doc = dom.window.document; 20 | }); 21 | 22 | it('throws if args undefined', () => { 23 | expect(() => walkDom()).throws('Top node'); 24 | }); 25 | 26 | it('throws if top node has a parent', () => { 27 | const bodyEl = doc.querySelectorAll('body')[0]; 28 | expect(() => walkDom(bodyEl)).throws('Top node'); 29 | }); 30 | 31 | it('throws if callbackFn is not a function', () => { 32 | expect(() => walkDom(doc.documentElement, undefined)).throws('callbackFn is not a function'); 33 | }); 34 | 35 | it('visits each node', () => { 36 | let numElements = 0; 37 | let numTextNodes = 0; 38 | let helloVisited = 0; 39 | let goodbyeVisited = 0; 40 | let bodyVisited = 0; 41 | let headVisited = 0; 42 | let titleVisited = 0; 43 | let htmlVisited = 0; 44 | walkDom(doc.documentElement, el => { 45 | if (el.nodeType === 1) { 46 | numElements++; 47 | } else if (el.nodeType === NodeType.TEXT) { 48 | numTextNodes++; 49 | } 50 | switch (el.localName) { 51 | case 'html': htmlVisited++; break; 52 | case 'head': headVisited++; break; 53 | case 'body': bodyVisited++; break; 54 | case 'title': titleVisited++; break; 55 | case 'p': 56 | if (el.textContent === 'hello') { 57 | helloVisited++; 58 | } else if (el.textContent === 'Goodbye') { 59 | goodbyeVisited++; 60 | } 61 | break; 62 | } 63 | }); 64 | 65 | // , , , <body>, and 2 <p>s. 66 | expect(numElements).equals(6); 67 | 68 | // The <title>, the two <p>s, and the two new-lines. 69 | expect(numTextNodes).equals(5); 70 | 71 | expect(helloVisited).equals(1); 72 | expect(goodbyeVisited).equals(1); 73 | expect(headVisited).equals(1); 74 | expect(bodyVisited).equals(1); 75 | expect(titleVisited).equals(1); 76 | expect(htmlVisited).equals(1); 77 | }); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '27 4 * * 5' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /service-worker.js: -------------------------------------------------------------------------------- 1 | const CACHE_NAME = 'kthoom:v5'; 2 | 3 | let urlsToCache = [ 4 | '.', 5 | 'code/bitjs/archive/archive.js', 6 | 'code/bitjs/archive/common.js', 7 | 'code/bitjs/archive/compress.js', 8 | 'code/bitjs/archive/decompress.js', 9 | 'code/bitjs/archive/decompress-internal.js', 10 | 'code/bitjs/archive/events.js', 11 | 'code/bitjs/archive/rarvm.js', 12 | 'code/bitjs/archive/unrar.js', 13 | 'code/bitjs/archive/untar.js', 14 | 'code/bitjs/archive/unzip.js', 15 | 'code/bitjs/archive/webworker-wrapper.js', 16 | 'code/bitjs/archive/zip.js', 17 | 'code/bitjs/file/sniffer.js', 18 | 'code/bitjs/image/webp-shim/webp-shim.js', 19 | 'code/bitjs/image/webp-shim/webp-shim-module.js', 20 | 'code/bitjs/image/webp-shim/webp-shim-module.wasm', 21 | 'code/bitjs/io/bitbuffer.js', 22 | 'code/bitjs/io/bitstream.js', 23 | 'code/bitjs/io/bytebuffer.js', 24 | 'code/bitjs/io/bytestream.js', 25 | 'code/comics/comic-book-binder.js', 26 | 'code/comics/comic-book-page-sorter.js', 27 | 'code/common/helpers.js', 28 | 'code/common/dom-walker.js', 29 | 'code/epub/epub-allowlists.js', 30 | 'code/epub/epub-book-binder.js', 31 | 'code/metadata/book-metadata.js', 32 | 'code/metadata/metadata-editor.js', 33 | 'code/metadata/metadata-viewer.js', 34 | 'code/pages/long-strip-page-setter.js', 35 | 'code/pages/one-page-setter.js', 36 | 'code/pages/page-container.js', 37 | 'code/pages/page-setter.js', 38 | 'code/pages/two-page-setter.js', 39 | 'code/pages/wide-strip-page-setter.js', 40 | 'code/book-binder.js', 41 | 'code/book-events.js', 42 | 'code/book-pump.js', 43 | 'code/book-viewer.js', 44 | 'code/book-viewer-types.js', 45 | 'code/book.js', 46 | 'code/config.js', 47 | 'code/file-ref.js', 48 | 'code/kthoom-google.js', 49 | 'code/kthoom-ipfs.js', 50 | 'code/kthoom-messages.js', 51 | 'code/kthoom.css', 52 | 'code/kthoom.js', 53 | 'code/main.js', 54 | 'code/menu.js', 55 | 'code/page.js', 56 | 'code/reading-stack.js', 57 | 'images/logo-192.png', 58 | 'images/logo.png', 59 | 'images/logo.svg', 60 | 'index.html', 61 | 'privacy.html', 62 | 'kthoom.webmanifest', 63 | ]; 64 | 65 | self.addEventListener('install', (evt) => { 66 | evt.waitUntil(async () => { 67 | const cache = await caches.open(CACHE_NAME); 68 | await cache.addAll(urlsToCache); 69 | }); 70 | }); 71 | 72 | self.addEventListener('fetch', (evt) => { 73 | evt.respondWith(async function () { 74 | try { 75 | const networkResponse = await fetch(evt.request); 76 | if (evt.request.method === 'GET') { 77 | const cache = await caches.open(CACHE_NAME); 78 | evt.waitUntil(cache.put(evt.request, networkResponse.clone())); 79 | } 80 | return networkResponse; 81 | } catch (err) { 82 | return caches.match(evt.request); 83 | } 84 | }()); 85 | }); -------------------------------------------------------------------------------- /code/bitjs/archive/common.js: -------------------------------------------------------------------------------- 1 | /** 2 | * common.js 3 | * 4 | * Provides common definitions or functionality needed by multiple modules. 5 | * 6 | * Licensed under the MIT License 7 | * 8 | * Copyright(c) 2023 Google Inc. 9 | */ 10 | 11 | /** 12 | * @typedef FileInfo An object that is sent to the implementation representing a file to compress. 13 | * @property {string} fileName The name of the file. TODO: Includes the path? 14 | * @property {number} lastModTime The number of ms since the Unix epoch (1970-01-01 at midnight). 15 | * @property {Uint8Array} fileData The bytes of the file. 16 | */ 17 | 18 | /** 19 | * @typedef Implementation 20 | * @property {MessagePort} hostPort The port the host uses to communicate with the implementation. 21 | * @property {Function} disconnectFn A function to call when the port has been disconnected. 22 | */ 23 | 24 | /** 25 | * Connects a host to a compress/decompress implementation via MessagePorts. The implementation must 26 | * have an exported connect() function that accepts a MessagePort. If the runtime support Workers 27 | * (e.g. web browsers, deno), imports the implementation inside a Web Worker. Otherwise, it 28 | * dynamically imports the implementation inside the current JS context (node, bun). 29 | * @param {string} implFilename The compressor/decompressor implementation filename relative to this 30 | * path (e.g. './unzip.js'). 31 | * @param {Function} disconnectFn A function to run when the port is disconnected. 32 | * @returns {Promise<Implementation>} The Promise resolves to the Implementation, which includes the 33 | * MessagePort connected to the implementation that the host should use. 34 | */ 35 | export async function getConnectedPort(implFilename) { 36 | const messageChannel = new MessageChannel(); 37 | const hostPort = messageChannel.port1; 38 | const implPort = messageChannel.port2; 39 | 40 | if (typeof Worker === 'undefined') { 41 | const implModule = await import(`${implFilename}`); 42 | await implModule.connect(implPort); 43 | return { 44 | hostPort, 45 | disconnectFn: () => implModule.disconnect(), 46 | }; 47 | } 48 | 49 | return new Promise((resolve, reject) => { 50 | const workerScriptPath = new URL(`./webworker-wrapper.js`, import.meta.url).href; 51 | const worker = new Worker(workerScriptPath, { type: 'module' }); 52 | worker.onmessage = () => { 53 | resolve({ 54 | hostPort, 55 | disconnectFn: () => worker.postMessage({ disconnect: true }), 56 | }); 57 | }; 58 | worker.postMessage({ implSrc: implFilename }, [implPort]); 59 | }); 60 | } 61 | 62 | // Zip-specific things. 63 | 64 | export const LOCAL_FILE_HEADER_SIG = 0x04034b50; 65 | export const CENTRAL_FILE_HEADER_SIG = 0x02014b50; 66 | export const END_OF_CENTRAL_DIR_SIG = 0x06054b50; 67 | export const CRC32_MAGIC_NUMBER = 0xedb88320; 68 | export const ARCHIVE_EXTRA_DATA_SIG = 0x08064b50; 69 | export const DIGITAL_SIGNATURE_SIG = 0x05054b50; 70 | export const END_OF_CENTRAL_DIR_LOCATOR_SIG = 0x07064b50; 71 | export const DATA_DESCRIPTOR_SIG = 0x08074b50; 72 | 73 | /** 74 | * @readonly 75 | * @enum {number} 76 | */ 77 | export const ZipCompressionMethod = { 78 | STORE: 0, // Default. 79 | DEFLATE: 8, // As per http://tools.ietf.org/html/rfc1951. 80 | }; 81 | -------------------------------------------------------------------------------- /tests/epub/epub-allowlists.test.js: -------------------------------------------------------------------------------- 1 | import 'mocha'; 2 | import { expect } from 'chai'; 3 | import { JSDOM } from 'jsdom'; 4 | import { EPUB_NAMESPACE, SVG_NAMESPACE, XLINK_NAMESPACE, 5 | isAllowedAttr, isAllowedElement } from '../../code/epub/epub-allowlists.js'; 6 | 7 | describe('EPUB Allowlists tests', () => { 8 | let doc; 9 | beforeEach(() => { 10 | const dom = new JSDOM('<html />'); 11 | doc = dom.window.document; 12 | }); 13 | 14 | describe('isAllowedAttr()', () => { 15 | let el; 16 | beforeEach(() => { 17 | el = doc.createElement('img'); 18 | }); 19 | 20 | it('throws on non-Attr', () => { 21 | const regex = /attr was not an Attr/; 22 | expect(() => isAllowedAttr(el, doc)).throws(regex); 23 | expect(() => isAllowedAttr(el, doc.createTextNode('hi'))).throws(regex); 24 | expect(() => isAllowedAttr(el, el)).throws(regex); 25 | expect(() => isAllowedAttr(el, doc.createComment('hi'))).throws(regex); 26 | expect(() => isAllowedAttr()).throws(regex); 27 | }); 28 | 29 | it('returns false on disallowed attribute', () => { 30 | expect(isAllowedAttr(el, doc.createAttribute('onclick'))).equals(false); 31 | }); 32 | 33 | it('returns true on allowed attributes', () => { 34 | expect(isAllowedAttr(el, doc.createAttribute('class'))).equals(true); 35 | expect(isAllowedAttr(el, doc.createAttribute('id'))).equals(true); 36 | expect(isAllowedAttr(el, doc.createAttribute('alt'))).equals(true); 37 | expect(isAllowedAttr(el, doc.createAttribute('src'))).equals(true); 38 | expect(isAllowedAttr(doc.createElement('link'), doc.createAttribute('href'))).equals(true); 39 | }); 40 | 41 | it('returns true for allowed XML namespaced attributes', () => { 42 | const image = doc.createElementNS(SVG_NAMESPACE, 'image'); 43 | expect(isAllowedAttr(image, doc.createAttributeNS(XLINK_NAMESPACE, 'href'))).equals(true); 44 | expect(isAllowedAttr(image, doc.createAttributeNS(EPUB_NAMESPACE, 'type'))).equals(true); 45 | }); 46 | }); 47 | 48 | describe('isAllowedElement()', () => { 49 | it('throws on non-Element', () => { 50 | const regex = /el was not an Element/; 51 | expect(() => isAllowedElement(doc)).throws(regex); 52 | expect(() => isAllowedElement(doc.createTextNode('hi'))).throws(regex); 53 | expect(() => isAllowedElement(doc.createAttribute('hi'))).throws(regex); 54 | expect(() => isAllowedElement(doc.createComment('hi'))).throws(regex); 55 | expect(() => isAllowedElement()).throws(regex); 56 | }); 57 | 58 | it('returns false on disallowed Element', () => { 59 | expect(isAllowedElement(doc.createElement('script'))).equals(false); 60 | }); 61 | 62 | it('returns true on allowed Elements', () => { 63 | expect(isAllowedElement(doc.createElement('p'))).equals(true); 64 | expect(isAllowedElement(doc.createElement('h1'))).equals(true); 65 | expect(isAllowedElement(doc.createElement('img'))).equals(true); 66 | expect(isAllowedElement(doc.createElement('head'))).equals(true); 67 | expect(isAllowedElement(doc.createElement('body'))).equals(true); 68 | expect(isAllowedElement(doc.createElementNS(SVG_NAMESPACE, 'image'))).equals(true); 69 | expect(isAllowedElement(doc.createElementNS(SVG_NAMESPACE, 'svg'))).equals(true); 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /code/pages/page-container.js: -------------------------------------------------------------------------------- 1 | import { getElem } from '../common/helpers.js'; 2 | 3 | /** @typedef {import('../book-viewer-types.js').Box} Box */ 4 | 5 | /** A class that manages the DOM elements of a page in the viewer. */ 6 | export class PageContainer { 7 | /** @type {SVGGElement} */ 8 | #g = null; 9 | 10 | /** @type {SVGImageElement} */ 11 | #image = null; 12 | 13 | /** @type {SVGForeignObjectElement} */ 14 | #foreignObject = null; 15 | 16 | /** 17 | * Clones and inflates the page container. 18 | */ 19 | constructor() { 20 | this.#g = getElem('pageTemplate').cloneNode(true); 21 | this.#g.removeAttribute('id'); 22 | this.#g.style.display = 'none'; 23 | this.#image = this.#g.querySelector('image'); 24 | this.#foreignObject = this.#g.querySelector('foreignObject'); 25 | } 26 | 27 | clear() { 28 | for (const el of [this.#image, this.#foreignObject]) { 29 | el.removeAttribute('x'); 30 | el.removeAttribute('y'); 31 | el.removeAttribute('height'); 32 | el.removeAttribute('width'); 33 | } 34 | this.#image.setAttribute('href', ''); 35 | while (this.#foreignObject.firstChild) { 36 | this.#foreignObject.lastChild.remove(); 37 | } 38 | } 39 | 40 | /** @returns {Box} */ 41 | getBox() { 42 | const left = parseInt(this.#image.getAttribute('x')); 43 | const top = parseInt(this.#image.getAttribute('y')); 44 | const width = parseInt(this.#image.getAttribute('width')); 45 | const height = parseInt(this.#image.getAttribute('height')); 46 | return { left, top, width, height }; 47 | } 48 | 49 | /** @returns {SVGGElement} */ 50 | getElement() { 51 | return this.#g; 52 | } 53 | 54 | /** @returns {number} */ 55 | getHeight() { 56 | return parseInt(this.#image.getAttribute('height')); 57 | } 58 | 59 | /** @returns {boolean} */ 60 | isShown() { 61 | return this.#g.style.display !== 'none'; 62 | } 63 | 64 | /** 65 | * Renders a chunk of HTML into the page container. 66 | * @param {HTMLElement} el The HTML element containing the contents of the page. 67 | * @param {number} pageNum The page number. 68 | */ 69 | renderHtml(el, pageNum) { 70 | this.#g.dataset.pagenum = pageNum; 71 | this.#image.style.display = 'none'; 72 | while (this.#foreignObject.firstChild) { 73 | this.#foreignObject.firstChild.remove(); 74 | } 75 | this.#foreignObject.appendChild(el); 76 | this.#foreignObject.style.display = ''; 77 | } 78 | 79 | /** 80 | * Renders a raster image into the page container. 81 | * @param {string} url The URL of the raster image. 82 | * @param {number} pageNum The page number. 83 | */ 84 | renderRasterImage(url, pageNum) { 85 | this.#g.dataset.pagenum = pageNum; 86 | this.#image.style.display = ''; 87 | this.#foreignObject.style.display = 'none'; 88 | this.#image.setAttribute('href', url); 89 | } 90 | 91 | /** @param {Box} box */ 92 | setFrame(box) { 93 | for (const el of [this.#image, this.#foreignObject]) { 94 | el.setAttribute('x', box.left); 95 | el.setAttribute('y', box.top); 96 | el.setAttribute('width', box.width); 97 | el.setAttribute('height', box.height); 98 | } 99 | } 100 | 101 | /** @param {boolean} isShown */ 102 | show(isShown) { 103 | this.#g.style.display = isShown ? '' : 'none'; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /code/bitjs/io/bytebuffer.js: -------------------------------------------------------------------------------- 1 | /* 2 | * bytebuffer.js 3 | * 4 | * Provides a writer for bytes. 5 | * 6 | * Licensed under the MIT License 7 | * 8 | * Copyright(c) 2023 Google Inc. 9 | * Copyright(c) 2011 antimatter15 10 | */ 11 | 12 | // TODO: Allow big-endian and little-endian, with consistent naming. 13 | 14 | /** 15 | * A write-only Byte buffer which uses a Uint8 Typed Array as a backing store. 16 | */ 17 | export class ByteBuffer { 18 | /** 19 | * @param {number} numBytes The number of bytes to allocate. 20 | */ 21 | constructor(numBytes) { 22 | if (typeof numBytes != typeof 1 || numBytes <= 0) { 23 | throw "Error! ByteBuffer initialized with '" + numBytes + "'"; 24 | } 25 | 26 | /** 27 | * @type {Uint8Array} 28 | * @public 29 | */ 30 | this.data = new Uint8Array(numBytes); 31 | 32 | /** 33 | * @type {number} 34 | * @public 35 | */ 36 | this.ptr = 0; 37 | } 38 | 39 | 40 | /** 41 | * @param {number} b The byte to insert. 42 | */ 43 | insertByte(b) { 44 | // TODO: throw if byte is invalid? 45 | this.data[this.ptr++] = b; 46 | } 47 | 48 | /** 49 | * @param {Array.<number>|Uint8Array|Int8Array} bytes The bytes to insert. 50 | */ 51 | insertBytes(bytes) { 52 | // TODO: throw if bytes is invalid? 53 | this.data.set(bytes, this.ptr); 54 | this.ptr += bytes.length; 55 | } 56 | 57 | /** 58 | * Writes an unsigned number into the next n bytes. If the number is too large 59 | * to fit into n bytes or is negative, an error is thrown. 60 | * @param {number} num The unsigned number to write. 61 | * @param {number} numBytes The number of bytes to write the number into. 62 | */ 63 | writeNumber(num, numBytes) { 64 | if (numBytes < 1 || !numBytes) { 65 | throw 'Trying to write into too few bytes: ' + numBytes; 66 | } 67 | if (num < 0) { 68 | throw 'Trying to write a negative number (' + num + 69 | ') as an unsigned number to an ArrayBuffer'; 70 | } 71 | if (num > (Math.pow(2, numBytes * 8) - 1)) { 72 | throw 'Trying to write ' + num + ' into only ' + numBytes + ' bytes'; 73 | } 74 | 75 | // Roll 8-bits at a time into an array of bytes. 76 | const bytes = []; 77 | while (numBytes-- > 0) { 78 | const eightBits = num & 255; 79 | bytes.push(eightBits); 80 | num >>= 8; 81 | } 82 | 83 | this.insertBytes(bytes); 84 | } 85 | 86 | /** 87 | * Writes a signed number into the next n bytes. If the number is too large 88 | * to fit into n bytes, an error is thrown. 89 | * @param {number} num The signed number to write. 90 | * @param {number} numBytes The number of bytes to write the number into. 91 | */ 92 | writeSignedNumber(num, numBytes) { 93 | if (numBytes < 1) { 94 | throw 'Trying to write into too few bytes: ' + numBytes; 95 | } 96 | 97 | const HALF = Math.pow(2, (numBytes * 8) - 1); 98 | if (num >= HALF || num < -HALF) { 99 | throw 'Trying to write ' + num + ' into only ' + numBytes + ' bytes'; 100 | } 101 | 102 | // Roll 8-bits at a time into an array of bytes. 103 | const bytes = []; 104 | while (numBytes-- > 0) { 105 | const eightBits = num & 255; 106 | bytes.push(eightBits); 107 | num >>= 8; 108 | } 109 | 110 | this.insertBytes(bytes); 111 | } 112 | 113 | /** 114 | * @param {string} str The ASCII string to write. 115 | */ 116 | writeASCIIString(str) { 117 | for (let i = 0; i < str.length; ++i) { 118 | const curByte = str.charCodeAt(i); 119 | if (curByte < 0 || curByte > 127) { 120 | throw 'Trying to write a non-ASCII string!'; 121 | } 122 | this.insertByte(curByte); 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /code/pages/one-page-setter.js: -------------------------------------------------------------------------------- 1 | /** Defines the most basic page setter of having just a single page. */ 2 | import { FitMode } from '../book-viewer-types.js'; 3 | import { PageSetter } from './page-setter.js'; 4 | 5 | /** @typedef {import('../book-viewer-types.js').Box} Box */ 6 | /** @typedef {import('../book-viewer-types.js').PageLayoutParams} PageLayoutParams */ 7 | /** @typedef {import('../book-viewer-types.js').PageSetting} PageSetting */ 8 | 9 | export class OnePageSetter extends PageSetter { 10 | /** 11 | * @param {PageLayoutParams} layoutParams 12 | * @returns {PageSetting} A set of Page bounding boxes. 13 | */ 14 | updateLayout(layoutParams) { 15 | const par = layoutParams.pageAspectRatio; 16 | const portraitMode = (layoutParams.rotateTimes % 2 === 0); 17 | const bv = layoutParams.bv; 18 | const bvar = bv.width / bv.height; 19 | 20 | // This is the center of rotation, always rotating around the center of the book viewer. 21 | const rotx = bv.left + bv.width / 2; 22 | const roty = bv.top + bv.height / 2; 23 | 24 | // This is the dimensions before transformation. They can go beyond the bv dimensions. 25 | let pw, ph, pl, pt; 26 | 27 | if (portraitMode) { 28 | // Portrait. 29 | if (layoutParams.fitMode === FitMode.Width || 30 | (layoutParams.fitMode === FitMode.Best && bvar <= par)) { 31 | // fit-width OR 32 | // fit-best, width maxed. 33 | pw = bv.width; 34 | ph = pw / par; 35 | pl = bv.left; 36 | if (par > bvar) { // not scrollable. 37 | pt = roty - ph / 2; 38 | } else { // fit-width, scrollable. 39 | pt = roty - bv.height / 2; 40 | if (layoutParams.rotateTimes === 2) { 41 | pt += bv.height - ph; 42 | } 43 | } 44 | } else { 45 | // fit-height, OR 46 | // fit-best, height maxed. 47 | ph = bv.height; 48 | pw = ph * par; 49 | pt = bv.top; 50 | if (par < bvar) { // not scrollable. 51 | pl = rotx - pw / 2; 52 | } else { // fit-height, scrollable. 53 | pl = bv.left; 54 | if (layoutParams.rotateTimes === 2) { 55 | pl += bv.width - pw; 56 | } 57 | } 58 | } 59 | 60 | if (bv.width < pw) bv.width = pw; 61 | if (bv.height < ph) bv.height = ph; 62 | } else { 63 | // Landscape. 64 | if (layoutParams.fitMode === FitMode.Width || 65 | (layoutParams.fitMode === FitMode.Best && par > (1 / bvar))) { 66 | // fit-best, width-maxed OR 67 | // fit-width. 68 | pw = bv.height; 69 | ph = pw / par; 70 | pl = rotx - pw / 2; 71 | if (par > (1 / bvar)) { // not scrollable. 72 | pt = roty - ph / 2; 73 | } else { // fit-width, scrollable. 74 | pt = roty - bv.width / 2; 75 | if (layoutParams.rotateTimes === 1) { 76 | pt += bv.width - ph; 77 | } 78 | } 79 | } else { 80 | // fit-best, height-maxed OR 81 | // fit-height. 82 | ph = bv.width; 83 | pw = ph * par; 84 | pt = roty - ph / 2; 85 | if (par < (1 / bvar)) { // not scrollable. 86 | pl = rotx - pw / 2; 87 | } else { // fit-height, scrollable. 88 | pl = rotx - bv.height / 2; 89 | if (layoutParams.rotateTimes === 3) { 90 | pl += bv.height - pw; 91 | } 92 | } 93 | } 94 | 95 | if (bv.width < ph) bv.width = ph; 96 | if (bv.height < pw) bv.height = pw; 97 | } // Landscape 98 | 99 | /** @type {PageSetting} */ 100 | return { 101 | boxes: [ 102 | { left: pl, top: pt, width: pw, height: ph }, 103 | ], 104 | bv, 105 | }; 106 | } 107 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Node.js CI](https://github.com/codedread/kthoom/actions/workflows/node.js.yml/badge.svg)](https://github.com/codedread/kthoom/actions/workflows/node.js.yml) 2 | [![CodeQL](https://github.com/starnowski/posmulten/workflows/CodeQL/badge.svg)](https://github.com/codedread/kthoom/actions/workflows/codeql-analysis.yml) 3 | 4 | # kthoom 5 | 6 | ![kthoom logo](images/logo.svg) 7 | 8 | kthoom is a comic book / ebook reader that runs in the browser using modern web technologies such as 9 | JavaScript, HTML5, the [File System Access API](https://wicg.github.io/file-system-access/), ES 10 | Modules, Web Workers, Typed Arrays, and more. It can open files and directories from your local 11 | file system, the network, or Google Drive. It can be embedded in larger web apps. 12 | 13 | It is built using pure JavaScript with no external dependencies and no JS frameworks. It can run 14 | out of the box without any build / compile / transpile / pack step, straight from the browser. 15 | Try it here: 16 | 17 | [OPEN KTHOOM COMIC BOOK READER](https://codedread.com/kthoom/index.html). 18 | 19 | You can also specify a comic book to load via the ?bookUri parameter. Some examples: 20 | 21 | * https://codedread.github.io/kthoom/index.html?bookUri=examples/codedread.cbz 22 | * https://codedread.github.io/kthoom/index.html?bookUri=examples/alice-in-wonderland.epub 23 | 24 | Or a [comic book reading list](https://github.com/codedread/kthoom/tree/master/reading-lists) via 25 | the ?readingListUri parameter. 26 | 27 | ## Documentation 28 | 29 | ### File Support 30 | 31 | * .cbz (zip) 32 | * .cbr ([rar](https://codedread.github.io/bitjs/docs/unrar.html)) 33 | * .cbt (tar) 34 | * .epub (Alpha-level support, a work-in-progress, see 35 | [issue list](https://github.com/codedread/kthoom/labels/epub)) 36 | 37 | ### Keyboard Shortcuts 38 | * O / D / U: Open books by choosing files/directories from computer or by URL. 39 | * Right/Left: Next/Previous page of book. 40 | * Shift + Right/Left: Last/First page of book. 41 | * [ / ]: Prev / Next book 42 | * H/W: Scale to height/width 43 | * B: Best Fit mode 44 | * R/L: Rotate right/left 45 | * 1/2: Show 1 or 2 pages side-by-side in the viewer. 46 | * 3: Long Strip viewer. 47 | * F: Toggle fullscreen. 48 | * P: Hide metadata viewer and reading stack panel buttons. 49 | * S: Toggle the Reading Stack tray open. 50 | * T: Toggle the Metadata Tag Viewer tray open. 51 | * ?: Bring up Help screen 52 | 53 | You can tell kthoom to open as many books as you like in the Choose Files dialog (shift-select all 54 | the books you want to open). Then navigate between books using the square bracket keys or use the 55 | Reading Stack tray. 56 | 57 | ### Binary File Support 58 | 59 | NOTE: kthoom loads in local compressed files and decompresses them in the browser, which means that 60 | kthoom has an implementation of unzip, unrar and untar in JavaScript. This code lives in its own 61 | library: [BitJS](https://github.com/codedread/bitjs), a more general purpose library to deal with 62 | binary file data in native JavaScript. Kthoom keeps an up-to-date version of bitjs in its 63 | repository. 64 | 65 | ### JSON Reading Lists 66 | 67 | kthoom supports loading lists of comic book files at once. Think audio playlists but for comic 68 | books! See [JSON Reading Lists](https://github.com/codedread/kthoom/tree/master/reading-lists) for 69 | more. 70 | 71 | ### URL parameters 72 | 73 | * alwaysOptimizedForStreaming=true: Tells kthoom to render pages immediately as they are 74 | de-compressed (this might not work for all comic books as some are not compressed in the order 75 | of reading) 76 | * bookUri=<url>: Tells kthoom to open the given book (cbz/cbr file). 77 | * doNotPromptOnClose=true: Tells kthoom not to ask the user if they are sure they want to close. 78 | * preventUserOpeningBooks=true: Prevents users from opening files in kthoom (useful for hosting 79 | kthoom from a web app). 80 | * readingListUri=<url>: Tells kthoom to load the given JSON Reading List (jrl) file and open 81 | the first file in that list. 82 | -------------------------------------------------------------------------------- /code/pages/two-page-setter.js: -------------------------------------------------------------------------------- 1 | /** Defines the most basic page setter of having just a single page. */ 2 | import { FitMode } from '../book-viewer-types.js'; 3 | import { PageSetter } from './page-setter.js'; 4 | 5 | /** @typedef {import('../book-viewer-types.js').Box} Box */ 6 | /** @typedef {import('../book-viewer-types.js').PageLayoutParams} PageLayoutParams */ 7 | /** @typedef {import('../book-viewer-types.js').PageSetting} PageSetting */ 8 | 9 | // TODO: Finish unit tests. 10 | 11 | export class TwoPageSetter extends PageSetter { 12 | /** 13 | * @param {PageLayoutParams} layoutParams 14 | * @returns {PageSetting} A set of Page bounding boxes. 15 | */ 16 | updateLayout(layoutParams) { 17 | const par = layoutParams.pageAspectRatio; 18 | const portraitMode = (layoutParams.rotateTimes % 2 === 0); 19 | const bv = layoutParams.bv; 20 | let bvar = bv.width / bv.height; 21 | 22 | // This is the center of rotation, always rotating around the center of the book viewer. 23 | const rotx = bv.left + bv.width / 2; 24 | const roty = bv.top + bv.height / 2; 25 | 26 | // These are the dimensions before transformation. They can go beyond the bv dimensions. 27 | let pw, ph, pl1, pt1, pl2, pt2; 28 | 29 | if (portraitMode) { 30 | // It is as if the book viewer width is cut in half horizontally for the purposes of 31 | // measuring the page fit. 32 | bvar /= 2; 33 | 34 | // Portrait, 2-page. 35 | if (layoutParams.fitMode === FitMode.Width || 36 | (layoutParams.fitMode === FitMode.Best && bvar <= par)) { 37 | // fit-width, 2-page. 38 | // fit-best, 2-page, width maxed. 39 | pw = bv.width / 2; 40 | ph = pw / par; 41 | pl1 = bv.left; 42 | if (par > bvar) { // not scrollable. 43 | pt1 = roty - ph / 2; 44 | } else { // fit-width, scrollable. 45 | pt1 = roty - bv.height / 2; 46 | if (layoutParams.rotateTimes === 2) { 47 | pt1 += bv.height - ph; 48 | } 49 | } 50 | } else { 51 | // fit-height, 2-page. 52 | // fit-best, 2-page, height maxed. 53 | ph = bv.height; 54 | pw = ph * par; 55 | pt1 = bv.top; 56 | if (par < bvar) { // not scrollable. 57 | pl1 = rotx - pw; 58 | } else { // fit-height, scrollable. 59 | pl1 = bv.left; 60 | if (layoutParams.rotateTimes === 2) { 61 | pl1 += bv.width - pw * 2; 62 | } 63 | } 64 | } 65 | 66 | if (bv.width < pw * 2) bv.width = pw * 2; 67 | if (bv.height < ph) bv.height = ph; 68 | } else { 69 | bvar *= 2; 70 | 71 | // Landscape, 2-page. 72 | if (layoutParams.fitMode === FitMode.Width || 73 | (layoutParams.fitMode === FitMode.Best && par > (1 / bvar))) { 74 | // fit-best, 2-page, width-maxed. 75 | // fit-width, 2-page. 76 | pw = bv.height / 2; 77 | ph = pw / par; 78 | pl1 = rotx - pw; 79 | if (par > (1 / bvar)) { // not scrollable. 80 | pt1 = roty - ph / 2; 81 | } else { // fit-width, scrollable. 82 | pt1 = roty - bv.width / 2; 83 | if (layoutParams.rotateTimes === 1) { 84 | pt1 += bv.width - ph; 85 | } 86 | } 87 | } else { 88 | // fit-best, 2-page, height-maxed. 89 | // fit-height, 2-page. 90 | ph = bv.width; 91 | pw = ph * par; 92 | pt1 = roty - ph / 2; 93 | if (par < (1 / bvar)) { // not scrollable. 94 | pl1 = rotx - pw; 95 | } else { // fit-height, scrollable. 96 | pl1 = rotx - bv.height / 2; 97 | if (layoutParams.rotateTimes === 3) { 98 | pl1 += bv.height - pw * 2; 99 | } 100 | } 101 | } 102 | 103 | if (bv.width < ph) bv.width = ph; 104 | if (bv.height < pw * 2) bv.height = pw * 2; 105 | } // Landscape 106 | 107 | pl2 = pl1 + pw; 108 | pt2 = pt1; 109 | 110 | /** @type {PageSetting} */ 111 | return { 112 | boxes: [ 113 | { left: pl1, top: pt1, width: pw, height: ph }, 114 | { left: pl2, top: pt2, width: pw, height: ph }, 115 | ], 116 | bv, 117 | }; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /tools/modules/archives/extract.go: -------------------------------------------------------------------------------- 1 | /** 2 | * archives/extract.go 3 | * 4 | * Licensed under the MIT License 5 | * 6 | * Copyright(c) 2021 Google Inc. 7 | */ 8 | 9 | // This package extracts an archive file. 10 | package archives 11 | 12 | import ( 13 | "fmt" 14 | "io" 15 | "io/ioutil" 16 | "os" 17 | "os/exec" 18 | "path/filepath" 19 | ) 20 | 21 | // This is how you do enums in go :-/ 22 | type ArchiveType int 23 | 24 | const ( 25 | Unknown ArchiveType = iota 26 | ComicBook 27 | EPub 28 | ) 29 | 30 | func (t ArchiveType) String() string { 31 | return [...]string{"Unknown", "ComicBook", "EPub"}[t] 32 | } 33 | 34 | type Archive struct { 35 | ArchiveFilename string 36 | ArchiveType 37 | TmpDir string 38 | Files []string 39 | } 40 | 41 | func CleanupArchive(archive *Archive) { 42 | if archive == nil || archive.TmpDir == "" { 43 | return 44 | } 45 | 46 | if err := os.RemoveAll(archive.TmpDir); err != nil { 47 | panic(err) 48 | } 49 | } 50 | 51 | func ExtractArchive(path string, outWriter io.Writer, errWriter io.Writer) (*Archive, error) { 52 | tmpdir, err := ioutil.TempDir("", "bish") 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | // Some archive files have incorrect extensions, so we cannot rely on them. We need to do some 58 | // byte sniffing instead. 59 | var file *os.File 60 | file, err = os.Open(path) 61 | if err != nil { 62 | return nil, err 63 | } 64 | defer file.Close() 65 | 66 | var header [4]byte 67 | if _, err = io.ReadFull(file, header[:]); err != nil { 68 | return nil, err 69 | } 70 | 71 | var cmd *exec.Cmd 72 | if header[0] == 0x52 && header[1] == 0x61 && header[2] == 0x72 && header[3] == 0x21 { // Rar! 73 | cmd = exec.Command("unrar", "x", path) 74 | } else if header[0] == 0x50 && header[1] == 0x4B { // PK (Zip) 75 | cmd = exec.Command("unzip", path) 76 | } 77 | 78 | cmd.Dir = tmpdir 79 | cmd.Stdout = outWriter 80 | cmd.Stderr = errWriter 81 | 82 | if err = cmd.Run(); err != nil { 83 | exitErr, ok := err.(*exec.ExitError) 84 | // If the error was not an ExitError, or the error code was not 3 (unrar CRC error code), 85 | // we bail. Otherwise we continue trying to process the unarchived files. 86 | if !ok || exitErr.ExitCode() != 3 { 87 | err = fmt.Errorf("%s had an error: %v", path, err) 88 | return nil, err 89 | } 90 | } 91 | 92 | theArchive := &Archive{ArchiveFilename: path, TmpDir: tmpdir} 93 | 94 | filepath.Walk(theArchive.TmpDir, func(f string, info os.FileInfo, err error) error { 95 | if err != nil { 96 | err = fmt.Errorf("%s had an error: %v", theArchive.ArchiveFilename, err) 97 | return err 98 | } 99 | 100 | fmt.Fprintf(outWriter, "Found an entry %s...", f) 101 | if !info.IsDir() { 102 | fmt.Fprintf(outWriter, "added file") 103 | theArchive.Files = append(theArchive.Files, f) 104 | } else { 105 | // Ignore sub-directories. 106 | } 107 | 108 | fmt.Fprintf(outWriter, "\n") 109 | return nil 110 | }) 111 | 112 | theArchive.ArchiveType = getArchiveType(theArchive) 113 | 114 | return theArchive, nil 115 | } 116 | 117 | func HasFile(archive *Archive, relPath string) bool { 118 | fullPath := filepath.Join(archive.TmpDir, relPath) 119 | for _, path := range archive.Files { 120 | if path == fullPath { 121 | return true 122 | } 123 | } 124 | return false 125 | } 126 | 127 | // TODO: Write a unit test for this. 128 | func getArchiveType(archive *Archive) ArchiveType { 129 | // If the archive has a META-INF/container.xml in it, it's probably an EPub. 130 | if HasFile(archive, "META-INF/container.xml") { 131 | return EPub 132 | } 133 | 134 | // Otherwise, if it ends in .cb? then it's probably a ComicBook. 135 | ext := filepath.Ext(archive.ArchiveFilename) 136 | if len(ext) == 4 && ext[1] == 'c' && ext[2] == 'b' { 137 | return ComicBook 138 | } 139 | 140 | // Otherwise, Unknown. 141 | return Unknown 142 | } 143 | 144 | func ReadFileFromArchive(archive *Archive, relPath string) ([]byte, error) { 145 | var absPath = filepath.Join(archive.TmpDir, relPath) 146 | fileContents, readErr := ioutil.ReadFile(absPath) 147 | if readErr != nil { 148 | err := fmt.Errorf("Could not read file from archive %s: %v", archive.ArchiveFilename, readErr) 149 | return nil, err 150 | } 151 | return fileContents, nil 152 | } 153 | -------------------------------------------------------------------------------- /code/bitjs/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * index.js 3 | * 4 | * Licensed under the MIT License 5 | * 6 | * Copyright(c) 2020 Google Inc. 7 | */ 8 | 9 | /** @typedef {import('./codecs/codecs.js').ProbeStream} ProbeStream */ 10 | /** @typedef {import('./codecs/codecs.js').ProbeFormat} ProbeFormat */ 11 | /** @typedef {import('./codecs/codecs.js').ProbeInfo} ProbeInfo */ 12 | 13 | /** @typedef {import('./image/parsers/gif.js').GifApplicationExtension} GifApplicationExtension */ 14 | /** @typedef {import('./image/parsers/gif.js').GifColor} GifColor */ 15 | /** @typedef {import('./image/parsers/gif.js').GifCommentExtension} GifCommentExtension */ 16 | /** @typedef {import('./image/parsers/gif.js').GifGraphicControlExtension} GifGraphicControlExtension */ 17 | /** @typedef {import('./image/parsers/gif.js').GifHeader} GifHeader */ 18 | /** @typedef {import('./image/parsers/gif.js').GifLogicalScreen} GifLogicalScreen */ 19 | /** @typedef {import('./image/parsers/gif.js').GifPlainTextExtension} GifPlainTextExtension */ 20 | /** @typedef {import('./image/parsers/gif.js').GifTableBasedImage} GifTableBasedImage */ 21 | 22 | /** @typedef {import('./image/parsers/jpeg.js').JpegApp0Extension} JpegApp0Extension */ 23 | /** @typedef {import('./image/parsers/jpeg.js').JpegApp0Marker} JpegApp0Marker */ 24 | /** @typedef {import('./image/parsers/jpeg.js').JpegComponentDetail} JpegComponentDetail */ 25 | /** @typedef {import('./image/parsers/jpeg.js').JpegDefineHuffmanTable} JpegDefineHuffmanTable */ 26 | /** @typedef {import('./image/parsers/jpeg.js').JpegDefineQuantizationTable} JpegDefineQuantizationTable */ 27 | /** @typedef {import('./image/parsers/jpeg.js').JpegStartOfFrame} JpegStartOfFrame */ 28 | /** @typedef {import('./image/parsers/jpeg.js').JpegStartOfScan} JpegStartOfScan */ 29 | 30 | /** @typedef {import('./image/parsers/png.js').PngBackgroundColor} PngBackgroundColor */ 31 | /** @typedef {import('./image/parsers/png.js').PngChromaticities} PngChromaticies */ 32 | /** @typedef {import('./image/parsers/png.js').PngColor} PngColor */ 33 | /** @typedef {import('./image/parsers/png.js').PngCompressedTextualData} PngCompressedTextualData */ 34 | /** @typedef {import('./image/parsers/png.js').PngHistogram} PngHistogram */ 35 | /** @typedef {import('./image/parsers/png.js').PngImageData} PngImageData */ 36 | /** @typedef {import('./image/parsers/png.js').PngImageGamma} PngImageGamma */ 37 | /** @typedef {import('./image/parsers/png.js').PngImageHeader} PngImageHeader */ 38 | /** @typedef {import('./image/parsers/png.js').PngIntlTextualData} PngIntlTextualData */ 39 | /** @typedef {import('./image/parsers/png.js').PngLastModTime} PngLastModTime */ 40 | /** @typedef {import('./image/parsers/png.js').PngPalette} PngPalette */ 41 | /** @typedef {import('./image/parsers/png.js').PngPhysicalPixelDimensions} PngPhysicalPixelDimensions */ 42 | /** @typedef {import('./image/parsers/png.js').PngSignificantBits} PngSignificantBits */ 43 | /** @typedef {import('./image/parsers/png.js').PngSuggestedPalette} PngSuggestedPalette */ 44 | /** @typedef {import('./image/parsers/png.js').PngSuggestedPaletteEntry} PngSuggestedPaletteEntry */ 45 | /** @typedef {import('./image/parsers/png.js').PngTextualData} PngTextualData */ 46 | /** @typedef {import('./image/parsers/png.js').PngTransparency} PngTransparency */ 47 | 48 | export { 49 | UnarchiveEvent, UnarchiveEventType, UnarchiveInfoEvent, UnarchiveErrorEvent, 50 | UnarchiveStartEvent, UnarchiveFinishEvent, UnarchiveProgressEvent, UnarchiveExtractEvent, 51 | Unarchiver, Unzipper, Unrarrer, Untarrer, getUnarchiver 52 | } from './archive/decompress.js'; 53 | export { getFullMIMEString, getShortMIMEString } from './codecs/codecs.js'; 54 | export { findMimeType } from './file/sniffer.js'; 55 | export { GifParseEventType, GifParser } from './image/parsers/gif.js'; 56 | export { JpegComponentType, JpegDctType, JpegDensityUnits, JpegExtensionThumbnailFormat, 57 | JpegHuffmanTableType, JpegParseEventType, JpegParser, 58 | JpegSegmentType } from './image/parsers/jpeg.js'; 59 | export { PngColorType, PngInterlaceMethod, PngParseEventType, PngParser, 60 | PngUnitSpecifier } from './image/parsers/png.js'; 61 | export { convertWebPtoPNG, convertWebPtoJPG } from './image/webp-shim/webp-shim.js'; 62 | export { BitBuffer } from './io/bitbuffer.js'; 63 | export { BitStream } from './io/bitstream.js'; 64 | export { ByteBuffer } from './io/bytebuffer.js'; 65 | export { ByteStream } from './io/bytestream.js'; 66 | -------------------------------------------------------------------------------- /images/logo.svg: -------------------------------------------------------------------------------- 1 | <?xml version='1.0'?> 2 | <svg viewBox="0 0 600 400" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="192px" height="192px"> 3 | <!-- Created with SVG-edit - http://svg-edit.googlecode.com/ --> 4 | <defs> 5 | <radialGradient id="svg_8" cx="0.5" cy="0.5" r="0.83143"> 6 | <stop offset="0" stop-color="#ffffff"/> 7 | <stop offset="0.7" stop-color="#ffff00"/> 8 | <stop offset="1" stop-color="#ffff00"/> 9 | </radialGradient> 10 | </defs> 11 | <title>kthoom logo 12 | 13 | 14 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 24 | 25 | 26 | 27 | 29 | 30 | 31 | 32 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /code/bitjs/archive/events.js: -------------------------------------------------------------------------------- 1 | /** 2 | * events.js 3 | * 4 | * Licensed under the MIT License 5 | * 6 | * Copyright(c) 2023 Google Inc. 7 | */ 8 | 9 | // TODO(2.0): Consider deprecating the Event subclasses here and: 10 | // 1) Make @typedef structures in jsdoc for all the payloads 11 | // 2) Use CustomEvent for payload event propagation 12 | // 3) Add semantic methods to the archivers (onExtract, onProgress) like the image parsers. 13 | // 4) Move everything into common.js ? 14 | 15 | /** 16 | * The UnarchiveEvent types. 17 | */ 18 | export const UnarchiveEventType = { 19 | START: 'start', 20 | APPEND: 'append', 21 | PROGRESS: 'progress', 22 | EXTRACT: 'extract', 23 | FINISH: 'finish', 24 | INFO: 'info', 25 | ERROR: 'error' 26 | }; 27 | 28 | // TODO: Use CustomEvent and a @template and remove these boilerplate events. 29 | 30 | /** An unarchive event. */ 31 | export class UnarchiveEvent extends Event { 32 | /** 33 | * @param {string} type The event type. 34 | */ 35 | constructor(type) { 36 | super(type); 37 | } 38 | } 39 | 40 | /** Updates all Unarchiver listeners that an append has occurred. */ 41 | export class UnarchiveAppendEvent extends UnarchiveEvent { 42 | /** 43 | * @param {number} numBytes The number of bytes appended. 44 | */ 45 | constructor(numBytes) { 46 | super(UnarchiveEventType.APPEND); 47 | 48 | /** 49 | * The number of appended bytes. 50 | * @type {number} 51 | */ 52 | this.numBytes = numBytes; 53 | } 54 | } 55 | 56 | /** Useful for passing info up to the client (for debugging). */ 57 | export class UnarchiveInfoEvent extends UnarchiveEvent { 58 | /** 59 | * @param {string} msg The info message. 60 | */ 61 | constructor(msg) { 62 | super(UnarchiveEventType.INFO); 63 | 64 | /** 65 | * The information message. 66 | * @type {string} 67 | */ 68 | this.msg = msg; 69 | } 70 | } 71 | 72 | /** An unrecoverable error has occured. */ 73 | export class UnarchiveErrorEvent extends UnarchiveEvent { 74 | /** 75 | * @param {string} msg The error message. 76 | */ 77 | constructor(msg) { 78 | super(UnarchiveEventType.ERROR); 79 | 80 | /** 81 | * The information message. 82 | * @type {string} 83 | */ 84 | this.msg = msg; 85 | } 86 | } 87 | 88 | /** Start event. */ 89 | export class UnarchiveStartEvent extends UnarchiveEvent { 90 | constructor() { 91 | super(UnarchiveEventType.START); 92 | } 93 | } 94 | 95 | /** Finish event. */ 96 | export class UnarchiveFinishEvent extends UnarchiveEvent { 97 | /** 98 | * @param {Object} metadata A collection of metadata about the archive file. 99 | */ 100 | constructor(metadata = {}) { 101 | super(UnarchiveEventType.FINISH); 102 | this.metadata = metadata; 103 | } 104 | } 105 | 106 | // TODO(bitjs): Fully document these. They are confusing. 107 | /** Progress event. */ 108 | export class UnarchiveProgressEvent extends UnarchiveEvent { 109 | /** 110 | * @param {string} currentFilename 111 | * @param {number} currentFileNumber 112 | * @param {number} currentBytesUnarchivedInFile 113 | * @param {number} currentBytesUnarchived 114 | * @param {number} totalUncompressedBytesInArchive 115 | * @param {number} totalFilesInArchive 116 | * @param {number} totalCompressedBytesRead 117 | */ 118 | constructor(currentFilename, currentFileNumber, currentBytesUnarchivedInFile, 119 | currentBytesUnarchived, totalUncompressedBytesInArchive, totalFilesInArchive, 120 | totalCompressedBytesRead) { 121 | super(UnarchiveEventType.PROGRESS); 122 | 123 | this.currentFilename = currentFilename; 124 | this.currentFileNumber = currentFileNumber; 125 | this.currentBytesUnarchivedInFile = currentBytesUnarchivedInFile; 126 | this.totalFilesInArchive = totalFilesInArchive; 127 | this.currentBytesUnarchived = currentBytesUnarchived; 128 | this.totalUncompressedBytesInArchive = totalUncompressedBytesInArchive; 129 | this.totalCompressedBytesRead = totalCompressedBytesRead; 130 | } 131 | } 132 | 133 | /** Extract event. */ 134 | export class UnarchiveExtractEvent extends UnarchiveEvent { 135 | /** 136 | * @param {UnarchivedFile} unarchivedFile 137 | */ 138 | constructor(unarchivedFile) { 139 | super(UnarchiveEventType.EXTRACT); 140 | 141 | /** 142 | * @type {UnarchivedFile} 143 | */ 144 | this.unarchivedFile = unarchivedFile; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /code/pages/wide-strip-page-setter.js: -------------------------------------------------------------------------------- 1 | /** Defines the most basic page setter of having just a single page. */ 2 | import { FitMode } from '../book-viewer-types.js'; 3 | import { PageSetter } from './page-setter.js'; 4 | import { assert } from '../common/helpers.js'; 5 | import { OnePageSetter } from './one-page-setter.js'; 6 | 7 | /** @typedef {import('../book-viewer-types.js').Box} Box */ 8 | /** @typedef {import('../book-viewer-types.js').PageLayoutParams} PageLayoutParams */ 9 | /** @typedef {import('../book-viewer-types.js').PageSetting} PageSetting */ 10 | 11 | // TODO: Add unit tests. 12 | 13 | export class WideStripPageSetter extends PageSetter { 14 | /** @type {num} */ 15 | #numPages = 0; 16 | 17 | /** @type {OnePageSetter} */ 18 | #onePageSetter = new OnePageSetter(); 19 | 20 | constructor() { 21 | super(); 22 | } 23 | 24 | /** 25 | * Get the scroll delta for the book viewer by a give # of pages. 26 | * @param {number} numPages The number of pages to scroll. 27 | * @param {Box} pageContainerBox The box of the page container. 28 | * @param {number} rotateTimes The # of clockwise 90-degree rotations. 29 | * @returns {Point} The number of pixels to scroll in x,y directions. 30 | */ 31 | getScrollDelta(numPages, pageContainerBox, rotateTimes) { 32 | const pxDeltaScroll = numPages * pageContainerBox.width; 33 | switch (rotateTimes) { 34 | case 0: return { x: 0, y: 0 }; 35 | case 1: return { x: 0, y: 0 }; 36 | case 2: return { x: pxDeltaScroll, y: 0 }; 37 | case 3: return { x: 0, y: pxDeltaScroll }; 38 | } 39 | throw `Invalid rotateTimes ${rotateTimes}`; 40 | } 41 | 42 | /** 43 | * @param {number} docScrollLeft The x-scroll position of the document. 44 | * @param {number} docScrollTop The y-scroll position of the document. 45 | * @param {Box[]} pageBoxes The dimensions of all visible PageContainers. 46 | * @param {number} rotateTimes The # of clockwise 90-degree rotations. 47 | * @returns {number} How far the viewer is scrolled into the book. 48 | */ 49 | getScrollPosition(docScrollLeft, docScrollTop, pageBoxes, rotateTimes) { 50 | if (pageBoxes.length === 0) { 51 | return 0; 52 | } 53 | 54 | const onePageWidth = pageBoxes[0].width; 55 | const fullWidth = pageBoxes.reduce((prev, cur) => prev + cur.width, 0); 56 | let scrollPosPx; 57 | switch (rotateTimes) { 58 | case 0: scrollPosPx = docScrollLeft; break; 59 | case 1: scrollPosPx = docScrollTop; break; 60 | case 2: scrollPosPx = fullWidth - docScrollLeft - onePageWidth; break; 61 | case 3: scrollPosPx = fullWidth - docScrollTop - onePageWidth; break; 62 | } 63 | 64 | return scrollPosPx / onePageWidth; 65 | } 66 | 67 | /** @param {number} np */ 68 | // TODO: Remove this special method and pass aspectRatios in as layoutParams below. 69 | setNumPages(np) { 70 | this.#numPages = np; 71 | } 72 | 73 | /** 74 | * @param {PageLayoutParams} layoutParams 75 | * @param {Box} bv The BookViewer bounding box. 76 | * @returns {PageSetting} A set of Page bounding boxes. 77 | */ 78 | updateLayout(layoutParams) { 79 | assert(this.#numPages > 0, `WideStripPageSetter.updateLayout() has #numPages=${this.#numPages}`); 80 | const portraitMode = (layoutParams.rotateTimes % 2 === 0); 81 | 82 | // Use the OnePageSetter to get the dimensions of the first page. 83 | // /** @type {PageSetting} */ 84 | const pageSetting = this.#onePageSetter.updateLayout(layoutParams); 85 | // And then use that to size the remaining PageSetting boxes, being careful to update the 86 | // book viewer dimensions as appropriate to the rotation. 87 | for (let i = 1; i < this.#numPages; ++i) { 88 | const prevBox = pageSetting.boxes[i - 1]; 89 | pageSetting.boxes.push({ 90 | left: prevBox.left + prevBox.width, 91 | top: prevBox.top, 92 | width: prevBox.width, 93 | height: prevBox.height, 94 | }); 95 | 96 | if (portraitMode) { 97 | pageSetting.bv.width += prevBox.width; 98 | if (layoutParams.rotateTimes === 2) { // 180 deg. 99 | pageSetting.bv.left -= prevBox.width; 100 | } 101 | } else { 102 | pageSetting.bv.height += prevBox.width; 103 | if (layoutParams.rotateTimes === 3) { // counter-clockwise. 104 | pageSetting.bv.top -= prevBox.width; 105 | } 106 | } 107 | } 108 | return pageSetting; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /code/bitjs/image/webp-shim/webp-shim.js: -------------------------------------------------------------------------------- 1 | /** 2 | * webp-shim.js 3 | * 4 | * Licensed under the MIT License 5 | * 6 | * Copyright(c) 2020 Google Inc. 7 | */ 8 | 9 | // TODO(2.0): Remove this. It seems unnecessary given WebP is universally supported now. 10 | 11 | const url = import.meta.url; 12 | if (!url.endsWith('/webp-shim.js')) { 13 | throw 'webp-shim must be loaded as webp-shim.js'; 14 | } 15 | const thisModulePath = url.substring(0, url.indexOf('/webp-shim.js')); 16 | 17 | let loadingPromise = undefined; 18 | let api = undefined; 19 | 20 | /** 21 | * @returns {Promise} Returns the API object. 22 | */ 23 | function loadWebPShimApi() { 24 | if (api) { return Promise.resolve(api); } 25 | else if (loadingPromise) { return loadingPromise; } 26 | return loadingPromise = new Promise((resolve, reject) => { 27 | const scriptEl = document.createElement('script'); 28 | scriptEl.onload = () => { 29 | Module.print = str => console.log(`${Date.now()}: ${str}`); 30 | Module.printErr = str => console.error(`${Date.now()}: ${str}`); 31 | Module.onRuntimeInitialized = () => { 32 | api = { 33 | createWASMBuffer: Module.cwrap('create_buffer', 'number', ['number', 'number']), 34 | destroyWASMBuffer: Module.cwrap('destroy_buffer', '', ['number']), 35 | getJPGHandle: Module.cwrap('get_jpg_handle_from_webp', 'number', ['number', 'number']), 36 | getPNGHandle: Module.cwrap('get_png_handle_from_webp', 'number', ['number', 'number']), 37 | getImageBytesFromHandle: Module.cwrap('get_image_bytes_from_handle', 'number', ['number']), 38 | getNumBytesFromHandle: Module.cwrap('get_num_bytes_from_handle', 'number', ['number']), 39 | module: Module, 40 | releaseImageHandle: Module.cwrap('release_image_handle', '', ['number']), 41 | }; 42 | resolve(api); 43 | }; 44 | }; 45 | scriptEl.onerror = err => reject(err); 46 | scriptEl.src = `${thisModulePath}/webp-shim-module.js`; 47 | document.body.appendChild(scriptEl); 48 | }); 49 | } 50 | 51 | /** 52 | * @param {ArrayBuffer|Uint8Array} webpBuffer The byte array containing the WebP image bytes. 53 | * @returns {Promise} A Promise resolving to a byte array containing the PNG bytes. 54 | */ 55 | export function convertWebPtoPNG(webpBuffer) { 56 | return loadWebPShimApi().then((api) => { 57 | // Create a buffer of the WebP bytes that we can send into WASM-land. 58 | const webpArray = new Uint8Array(webpBuffer); 59 | const size = webpArray.byteLength; 60 | const webpWASMBuffer = api.createWASMBuffer(size); 61 | api.module.HEAPU8.set(webpArray, webpWASMBuffer); 62 | 63 | // Convert to PNG. 64 | const pngHandle = api.getPNGHandle(webpWASMBuffer, size); 65 | if (!pngHandle) { 66 | api.destroyWASMBuffer(webpWASMBuffer); 67 | return null; 68 | } 69 | const numPNGBytes = api.getNumBytesFromHandle(pngHandle); 70 | const pngBufPtr = api.getImageBytesFromHandle(pngHandle); 71 | let pngBuffer = api.module.HEAPU8.slice(pngBufPtr, pngBufPtr + numPNGBytes - 1); 72 | 73 | // Cleanup. 74 | api.releaseImageHandle(pngHandle); 75 | api.destroyWASMBuffer(webpWASMBuffer); 76 | return pngBuffer; 77 | }); 78 | } 79 | 80 | /** 81 | * @param {ArrayBuffer|Uint8Array} webpBuffer The byte array containing the WebP image bytes. 82 | * @returns {Promise} A Promise resolving to a byte array containing the JPG bytes. 83 | */ 84 | export function convertWebPtoJPG(webpBuffer) { 85 | return loadWebPShimApi().then((api) => { 86 | // Create a buffer of the WebP bytes that we can send into WASM-land. 87 | const webpArray = new Uint8Array(webpBuffer); 88 | const size = webpArray.byteLength; 89 | const webpWASMBuffer = api.createWASMBuffer(size); 90 | api.module.HEAPU8.set(webpArray, webpWASMBuffer); 91 | 92 | // Convert to JPG. 93 | const jpgHandle = api.getJPGHandle(webpWASMBuffer, size); 94 | if (!jpgHandle) { 95 | api.destroyWASMBuffer(webpWASMBuffer); 96 | return null; 97 | } 98 | const numJPGBytes = api.getNumBytesFromHandle(jpgHandle); 99 | const jpgBufPtr = api.getImageBytesFromHandle(jpgHandle); 100 | const jpgBuffer = api.module.HEAPU8.slice(jpgBufPtr, jpgBufPtr + numJPGBytes - 1); 101 | 102 | // Cleanup. 103 | api.releaseImageHandle(jpgHandle); 104 | api.destroyWASMBuffer(webpWASMBuffer); 105 | return jpgBuffer; 106 | }); 107 | } 108 | -------------------------------------------------------------------------------- /code/pages/long-strip-page-setter.js: -------------------------------------------------------------------------------- 1 | /** Defines the most basic page setter of having just a single page. */ 2 | import { FitMode } from '../book-viewer-types.js'; 3 | import { PageSetter } from './page-setter.js'; 4 | import { assert } from '../common/helpers.js'; 5 | import { OnePageSetter } from './one-page-setter.js'; 6 | 7 | /** @typedef {import('../book-viewer-types.js').Box} Box */ 8 | /** @typedef {import('../book-viewer-types.js').Point} Point */ 9 | /** @typedef {import('../book-viewer-types.js').PageLayoutParams} PageLayoutParams */ 10 | /** @typedef {import('../book-viewer-types.js').PageSetting} PageSetting */ 11 | 12 | // TODO: Add unit tests. 13 | 14 | export class LongStripPageSetter extends PageSetter { 15 | /** @type {num} */ 16 | #numPages = 0; 17 | 18 | /** @type {OnePageSetter} */ 19 | #onePageSetter = new OnePageSetter(); 20 | 21 | constructor() { 22 | super(); 23 | } 24 | 25 | /** 26 | * Get the scroll delta for the book viewer by a give # of pages. 27 | * @param {number} numPages The number of pages to scroll. 28 | * @param {Box} pageContainerBox The box of the page container. 29 | * @param {number} rotateTimes The # of clockwise 90-degree rotations. 30 | * @returns {Point} The number of pixels to scroll in x,y directions. 31 | */ 32 | getScrollDelta(numPages, pageContainerBox, rotateTimes) { 33 | const pxDeltaScroll = numPages * pageContainerBox.height; 34 | switch (rotateTimes) { 35 | case 0: return { x: 0, y: 0 }; 36 | case 1: return { x: pxDeltaScroll, y: 0 }; 37 | case 2: return { x: 0, y: pxDeltaScroll }; 38 | case 3: return { x: 0, y: 0 }; 39 | } 40 | throw `Invalid rotateTimes ${rotateTimes}`; 41 | } 42 | 43 | /** 44 | * @param {number} docScrollLeft The x-scroll position of the document. 45 | * @param {number} docScrollTop The y-scroll position of the document. 46 | * @param {Box[]} pageBoxes The dimensions of all visible PageContainers. 47 | * @param {number} rotateTimes The # of clockwise 90-degree rotations. 48 | * @returns {number} How far the viewer is scrolled into the book. 49 | */ 50 | getScrollPosition(docScrollLeft, docScrollTop, pageBoxes, rotateTimes) { 51 | if (pageBoxes.length === 0) { 52 | return 0; 53 | } 54 | 55 | const onePageHeight = pageBoxes[0].height; 56 | const fullHeight = pageBoxes.reduce((prev, cur) => prev + cur.height, 0); 57 | let scrollPosPx; 58 | switch (rotateTimes) { 59 | case 0: scrollPosPx = docScrollTop; break; 60 | case 1: scrollPosPx = fullHeight - docScrollLeft - onePageHeight; break; 61 | case 2: scrollPosPx = fullHeight - docScrollTop - onePageHeight; break; 62 | case 3: scrollPosPx = docScrollLeft; break; 63 | } 64 | 65 | return scrollPosPx / onePageHeight; 66 | } 67 | 68 | /** @param {number} np */ 69 | // TODO: Remove this special method and pass aspectRatios in as layoutParams below. 70 | setNumPages(np) { 71 | this.#numPages = np; 72 | } 73 | 74 | /** 75 | * @param {PageLayoutParams} layoutParams 76 | * @param {Box} bv The BookViewer bounding box. 77 | * @returns {PageSetting} A set of Page bounding boxes. 78 | */ 79 | updateLayout(layoutParams) { 80 | assert(this.#numPages > 0, `LongStripPageSetter.updateLayout() has #numPages=${this.#numPages}`); 81 | const portraitMode = (layoutParams.rotateTimes % 2 === 0); 82 | 83 | // Use the OnePageSetter to get the dimensions of the first page. 84 | // /** @type {PageSetting} */ 85 | const pageSetting = this.#onePageSetter.updateLayout(layoutParams); 86 | // And then use that to size the remaining PageSetting boxes, being careful to update the 87 | // book viewer dimensions as appropriate to the rotation. 88 | for (let i = 1; i < this.#numPages; ++i) { 89 | const prevBox = pageSetting.boxes[i - 1]; 90 | pageSetting.boxes.push({ 91 | left: prevBox.left, 92 | top: prevBox.top + prevBox.height, 93 | width: prevBox.width, 94 | height: prevBox.height, 95 | }); 96 | 97 | if (portraitMode) { 98 | pageSetting.bv.height += prevBox.height; 99 | if (layoutParams.rotateTimes === 2) { // 180 deg. 100 | pageSetting.bv.top -= prevBox.height; 101 | } 102 | } else { 103 | pageSetting.bv.width += prevBox.height; 104 | if (layoutParams.rotateTimes === 1) { // counter-clockwise. 105 | pageSetting.bv.left -= prevBox.height; 106 | } 107 | } 108 | } 109 | return pageSetting; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /tools/modules/books/comic/comic.go: -------------------------------------------------------------------------------- 1 | /** 2 | * books/comic.go 3 | * 4 | * Licensed under the MIT License 5 | * 6 | * Copyright(c) 2021 Google Inc. 7 | */ 8 | 9 | // This package extracts a comic book file. 10 | package comic 11 | 12 | import ( 13 | "encoding/xml" 14 | "fmt" 15 | "io" 16 | "io/ioutil" 17 | "os/exec" 18 | "path/filepath" 19 | "strings" 20 | 21 | "github.com/codedread/kthoom/tools/modules/archives" 22 | "github.com/codedread/kthoom/tools/modules/books/metadata" 23 | ) 24 | 25 | var filenamesToIgnore = []string{ 26 | "thumbs.db", 27 | } 28 | 29 | const metadataFilename = "comicinfo.xml" 30 | 31 | type ComicInfo struct { 32 | XMLName xml.Name 33 | ArchiveFileInfo *metadata.ArchiveFileInfo 34 | Attributes []xml.Attr `xml:",any,attr"` 35 | AnyNodes []metadata.AnyNode `xml:",any"` 36 | } 37 | 38 | type Book struct { 39 | *archives.Archive 40 | MetadataFilename string 41 | Metadata *ComicInfo 42 | PageFiles []string 43 | } 44 | 45 | func ExtractBookFromFilename(archiveFilename string, outWriter io.Writer, errWriter io.Writer) (*Book, error) { 46 | theArchive, err := archives.ExtractArchive(archiveFilename, outWriter, errWriter) 47 | if err != nil { 48 | err = fmt.Errorf("Extracting %s had an error: %v", archiveFilename, err) 49 | return nil, err 50 | } 51 | 52 | return ExtractBookFromArchive(theArchive, outWriter, errWriter) 53 | } 54 | 55 | func ExtractBookFromArchive(theArchive *archives.Archive, outWriter io.Writer, errWriter io.Writer) (*Book, error) { 56 | book := &Book{Archive: theArchive} 57 | 58 | for _, filename := range book.Files { 59 | if strings.ToLower(filename) == metadataFilename { 60 | // Keeps the original capitalization of the filename. 61 | book.MetadataFilename = filename 62 | continue 63 | } 64 | 65 | // Skip any files that need ignoring. 66 | ignore := false 67 | for _, n := range filenamesToIgnore { 68 | if strings.ToLower(filename) == n { 69 | ignore = true 70 | break 71 | } 72 | } 73 | if ignore { 74 | continue 75 | } 76 | 77 | book.PageFiles = append(book.PageFiles, filename) 78 | } 79 | 80 | err := findMetadata(book, outWriter, errWriter) 81 | if err != nil { 82 | err = fmt.Errorf("findMetadata() on %s had an error: %v", theArchive.ArchiveFilename, err) 83 | return nil, err 84 | } 85 | 86 | return book, nil 87 | } 88 | 89 | func CreateArchiveFromBook(theBook *Book, outArchiveFilename string, outWriter io.Writer, errWriter io.Writer) error { 90 | // Create metadata file. 91 | outputXml, marshalErr := xml.MarshalIndent(theBook.Metadata, "", " ") 92 | if marshalErr != nil { 93 | return fmt.Errorf("Failed to marshal XML metadata: %v\n", marshalErr.Error()) 94 | } 95 | 96 | metadataFilename := filepath.Join(theBook.TmpDir, "ComicInfo.xml") 97 | if writeErr := ioutil.WriteFile(metadataFilename, outputXml, 0644); writeErr != nil { 98 | return fmt.Errorf("Failed to create metadata file for %s: %v\n", metadataFilename, writeErr) 99 | } 100 | fmt.Fprintf(outWriter, "Created metadata file: %s\n", metadataFilename) 101 | 102 | zipMetadataCmd := exec.Command("/usr/bin/zip", "-j", "-0", outArchiveFilename, metadataFilename) 103 | zipMetadataCmd.Stdout = outWriter 104 | zipMetadataCmd.Stderr = errWriter 105 | if err := zipMetadataCmd.Run(); err != nil { 106 | return fmt.Errorf("Adding zip metadata finished with error: %v\n", err) 107 | } 108 | 109 | // Now add all the pages (images). 110 | var zipArgs = append([]string{"-j", "-9", outArchiveFilename}, theBook.PageFiles...) 111 | zipCmd := exec.Command("/usr/bin/zip", zipArgs...) 112 | zipCmd.Stdout = outWriter 113 | zipCmd.Stderr = errWriter 114 | 115 | if err := zipCmd.Run(); err != nil { 116 | return fmt.Errorf("Adding zip page finished with error: %v\n", err) 117 | } 118 | 119 | return nil 120 | } 121 | 122 | func findMetadata(book *Book, outWriter io.Writer, errWriter io.Writer) error { 123 | // Unmarshal XML metadata. 124 | var err error 125 | if book.MetadataFilename != "" { 126 | metadata, readErr := archives.ReadFileFromArchive(book.Archive, book.MetadataFilename) 127 | if readErr != nil { 128 | err := fmt.Errorf("Could not read metadata file in %s: %v", book.ArchiveFilename, readErr) 129 | return err 130 | } 131 | 132 | book.Metadata = &ComicInfo{} 133 | err = xml.Unmarshal(metadata, &book.Metadata) 134 | if err != nil { 135 | err = fmt.Errorf("%s had an error when unmarshalling XML: %v", book.ArchiveFilename, err) 136 | return err 137 | } 138 | } 139 | 140 | return err 141 | } 142 | -------------------------------------------------------------------------------- /code/metadata/metadata-viewer.js: -------------------------------------------------------------------------------- 1 | import { Book } from '../book.js'; 2 | import { Key, Params, getElem } from '../common/helpers.js'; 3 | 4 | export class MetadataViewer { 5 | constructor() { 6 | /** 7 | * @private 8 | * @type {Book} 9 | */ 10 | this.book_ = null; 11 | 12 | /** 13 | * @private 14 | * @type {HTMLDivElement} 15 | */ 16 | this.contentDiv_ = getElem('metadataTrayContents'); 17 | 18 | /** 19 | * @private 20 | * @type {HTMLTemplateElement} 21 | */ 22 | this.tableTemplate_ = getElem('metadataTable'); 23 | 24 | /** 25 | * @private 26 | * @type {MetadataEditor} 27 | */ 28 | this.editor_ = null; 29 | 30 | getElem('metadataViewerButton').addEventListener('click', () => this.toggleOpen()); 31 | getElem('metadataViewerOverlay').addEventListener('click', () => this.toggleOpen()); 32 | getElem('closeMetadataButton').addEventListener('click', () => this.doClose()); 33 | 34 | // Only show the toolbar if editMetadata flag is true and the browser supports the 35 | // File System Access API (for now). 36 | if (window['showSaveFilePicker']) { 37 | const toolbarDiv = getElem('metadataToolbar'); 38 | toolbarDiv.style.display = ''; 39 | getElem('editMetadataButton').addEventListener('click', () => this.doEdit()); 40 | } 41 | } 42 | 43 | /** 44 | * If the editor is open, close that and release the editor. Otherwise, close the MetadataViewer 45 | * tray. 46 | */ 47 | doClose() { 48 | if (!this.isOpen()) { 49 | return; 50 | } 51 | 52 | if (this.editor_) { 53 | // doClose() returning true means the editor should be released. 54 | if (this.editor_.doClose()) { 55 | this.editor_ = null; 56 | const editButton = getElem('editMetadataButton'); 57 | editButton.style.display = ''; 58 | this.rerender_(); 59 | } 60 | } else { 61 | this.toggleOpen(); 62 | } 63 | } 64 | 65 | /** Load the code for MetadataEditor and show it. */ 66 | doEdit() { 67 | if (this.editor_) { 68 | return; 69 | } 70 | 71 | import('./metadata-editor.js').then(module => { 72 | getElem('editMetadataButton').style.display = 'none'; 73 | this.editor_ = new module.MetadataEditor(this.book_); 74 | this.editor_.doOpen(); 75 | }); 76 | } 77 | 78 | /** 79 | * @param {KeyboardEvent} evt 80 | * @returns {boolean} True if the event was handled. 81 | */ 82 | handleKeyEvent(evt) { 83 | if (!this.isOpen()) { 84 | return false; 85 | } 86 | 87 | if (this.editor_) { 88 | return this.editor_.handleKeyEvent(evt); 89 | } 90 | 91 | switch (evt.keyCode) { 92 | case Key.T: this.doClose(); break; 93 | case Key.E: this.doEdit(); break; 94 | } 95 | 96 | return true; 97 | } 98 | 99 | /** @returns {boolean} */ 100 | isOpen() { 101 | return getElem('metadataViewer').classList.contains('opened'); 102 | } 103 | 104 | reset() { 105 | this.book_ = null; 106 | this.rerender_(); 107 | } 108 | 109 | /** 110 | * Called to set the state of the metadata viewer and render it. 111 | * @param {Book} book 112 | */ 113 | setBook(book) { 114 | this.book_ = book; 115 | this.rerender_(); 116 | } 117 | 118 | /** @param {boolean} show */ 119 | showButton(show) { 120 | getElem('metadataViewerButton').classList.toggle('hidden', !show); 121 | } 122 | 123 | /** 124 | * Opens or closes the metadata viewer pane. Only works if the MetadataViewer has a book and only 125 | * if the Editor is not open. 126 | */ 127 | toggleOpen() { 128 | if (!this.book_) { 129 | return; 130 | } 131 | // TODO: Let the user know they need to close the metadata editor first via a toast or callout. 132 | if (this.editor_) { 133 | return; 134 | } 135 | getElem('metadataViewer').classList.toggle('opened'); 136 | getElem('metadataViewerOverlay').classList.toggle('hidden'); 137 | } 138 | 139 | /** @private */ 140 | rerender_() { 141 | if (this.book_) { 142 | const metadata = this.book_.getMetadata(); 143 | const metadataContents = document.importNode(this.tableTemplate_.content, true); 144 | const tableElem = metadataContents.querySelector('table.metadataTable'); 145 | const rowTemplate = getElem('metadataTableRow'); 146 | for (const [key, value] of metadata.propertyEntries()) { 147 | if (key && value) { 148 | const rowElem = document.importNode(rowTemplate.content, true); 149 | rowElem.querySelector('td.metadataPropName').textContent = key; 150 | rowElem.querySelector('td.metadataPropValue').textContent = value; 151 | tableElem.appendChild(rowElem); 152 | } 153 | } 154 | 155 | this.contentDiv_.innerHTML = ''; 156 | this.contentDiv_.appendChild(tableElem); 157 | 158 | const hasMetadata = Array.from(metadata.propertyEntries()).length > 0; 159 | getElem('metadataIsPresent').style.display = hasMetadata ? '' : 'none'; 160 | } else { 161 | this.contentDiv_.innerHTML = 'No book loaded'; 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /code/metadata/book-metadata.js: -------------------------------------------------------------------------------- 1 | import { BookType } from "../book-binder.js"; 2 | 3 | const STREAM_OPTIMIZED_NS = 'http://www.codedread.com/sop'; 4 | 5 | /** @enum */ 6 | export const ComicBookMetadataType = { 7 | UNKNOWN: 0, 8 | COMIC_RACK: 1, 9 | }; 10 | 11 | /** 12 | * ComicRack. Schema now maintained here, apparently: https://github.com/anansi-project/comicinfo 13 | * We will use the following fields for now. 14 | * TODO: Add in Characters (a comma-separated list) and Summary (a long string). 15 | */ 16 | const COMICRACK_KEYS = [ 17 | 'Title', 'Series', 'Number', 'Volume', 'Year', 'Month', 'Writer', 'Penciller', 'Inker', 18 | 'Colorist', 'Letterer', 'CoverArtist', 'Editor', 'Publisher', 'Genre', 19 | ]; 20 | 21 | /** 22 | * A lightweight class to encapsulate metadata of a book. This will 23 | * hide the differences between metadata formats from kthoom. 24 | */ 25 | export class BookMetadata { 26 | /** 27 | * @param {BookType} bookType The type of the book. 28 | * @param {Iterable} tagMap The key-value metadata tags. 29 | * @param {boolean} optimizedForStreaming Whether this book is optimized for streaming, meaning 30 | * files in the archive are in read order. 31 | */ 32 | constructor(bookType, tagMap = new Map(), optimizedForStreaming = false) { 33 | /** @private {BookType} */ 34 | this.bookType_ = bookType; 35 | 36 | /** @private {Map} */ 37 | this.tags_ = new Map(tagMap); 38 | 39 | /** @private {boolean} */ 40 | this.optimizedForStreaming_ = optimizedForStreaming; 41 | } 42 | 43 | /** @returns {BookMetadata} */ 44 | clone() { 45 | return new BookMetadata(this.bookType_, this.tags_, this.optimizedForStreaming_); 46 | } 47 | 48 | /** 49 | * @param {BookMetadata} o 50 | * @returns {boolean} Whether o is equivalent to this metadata object. 51 | */ 52 | equals(o) { 53 | if (this.bookType_ !== o.bookType_) { 54 | return false; 55 | } 56 | if (this.optimizedForStreaming_ !== o.isOptimizedForStreaming()) { 57 | return false; 58 | } 59 | const otherEntries = o.propertyEntries(); 60 | if (Array.from(otherEntries).length !== Array.from(this.tags_.keys()).length) { 61 | return false; 62 | } 63 | for (const [key, val] of otherEntries) { 64 | if (!this.tags_.has(key) || this.tags_.get(key) !== val) { 65 | return false; 66 | } 67 | } 68 | return true; 69 | } 70 | 71 | /** @returns {string[]} */ 72 | getAllowedPropertyKeys() { 73 | if (this.bookType_ === ComicBookMetadataType.COMIC_RACK) { 74 | return COMICRACK_KEYS; 75 | } 76 | return []; 77 | } 78 | 79 | /** @returns {ComicBookMetadataType} */ 80 | getBookType() { 81 | return this.bookType_; 82 | } 83 | 84 | /** 85 | * @param {string} key 86 | * @returns {string} 87 | */ 88 | getProperty(key) { 89 | return this.tags_.get(key); 90 | } 91 | 92 | /** 93 | * @param {string} key 94 | * @returns {boolean} 95 | */ 96 | hasProperty(key) { 97 | return this.tags_.has(key); 98 | } 99 | 100 | /** @returns {boolean} */ 101 | isOptimizedForStreaming() { return this.optimizedForStreaming_; } 102 | 103 | /** @returns {Iterable} A list of key-value pairs, similar to Object.entries(). */ 104 | propertyEntries() { 105 | return this.tags_.entries(); 106 | } 107 | 108 | /** @param {string} key */ 109 | removeProperty(key) { 110 | this.tags_.delete(key); 111 | } 112 | 113 | /** 114 | * @param {string} key 115 | * @param {string} value 116 | */ 117 | setProperty(key, value) { 118 | this.tags_.set(key, value); 119 | } 120 | } 121 | 122 | /** 123 | * @param {BookType} bookType Defaults to COMIC. 124 | * @returns {BookMetadata} 125 | */ 126 | export function createEmptyMetadata(bookType = BookType.COMIC) { 127 | return new BookMetadata(bookType); 128 | } 129 | 130 | /** 131 | * @param {string} metadataXml The text contents of the ComicInfo.xml file. 132 | * @returns {BookMetadata} 133 | */ 134 | export function createMetadataFromComicBookXml(metadataXml) { 135 | const metadataDoc = new DOMParser().parseFromString(metadataXml, 'text/xml'); 136 | 137 | // Figure out if this XML file indicates the archive is optimized for streaming. 138 | let optimizedForStreaming = false; 139 | const infoEls = metadataDoc.getElementsByTagNameNS(STREAM_OPTIMIZED_NS, 140 | 'ArchiveFileInfo'); 141 | if (infoEls && infoEls.length > 0) { 142 | const infoEl = infoEls.item(0); 143 | if (infoEl.getAttribute('optimizedForStreaming') === 'true') { 144 | optimizedForStreaming = true; 145 | } 146 | } 147 | 148 | // Extract all known key-value pairs. 149 | const tagMap = new Map(); 150 | for (const key of COMICRACK_KEYS) { 151 | let val = metadataDoc?.querySelector(key)?.textContent; 152 | if (val) { 153 | tagMap.set(key, val); 154 | } 155 | } 156 | 157 | return new BookMetadata(BookType.COMIC, tagMap, optimizedForStreaming); 158 | } 159 | 160 | /** 161 | * @param {BookMetadata} metadata 162 | * @returns {string} The XML text of the metadata for ComicInfo.xml. 163 | */ 164 | export function createComicBookXmlFromMetadata(metadata) { 165 | let xmlStr = `\n`; 166 | 167 | if (metadata.isOptimizedForStreaming()) { 168 | xmlStr += ` \n`; 169 | } 170 | 171 | for (const [key, val] of metadata.propertyEntries()) { 172 | if (COMICRACK_KEYS.includes(key)) { 173 | // TODO: Sanitize these values? 174 | xmlStr += ` <${key}>${val}\n`; 175 | } 176 | } 177 | 178 | xmlStr += `\n`; 179 | return xmlStr; 180 | } -------------------------------------------------------------------------------- /tools/desadulate/go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 2 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 3 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 4 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 5 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 6 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 7 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 8 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 9 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 10 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 11 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 12 | golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 13 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 14 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 15 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 16 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 17 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 18 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 19 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 20 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 21 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 22 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 23 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 24 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 25 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 26 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 27 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 28 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 29 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 30 | golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 31 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 32 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 33 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 34 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 35 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 36 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 37 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 38 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 39 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 40 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 41 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 42 | golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 43 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 44 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 45 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 46 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 47 | golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= 48 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 49 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 50 | golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 51 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 52 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 53 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 54 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 55 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 56 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 57 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 58 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 59 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 60 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 61 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 62 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 63 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 64 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 65 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 66 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 67 | -------------------------------------------------------------------------------- /tools/modules/books/go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 2 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 3 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 4 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 5 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 6 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 7 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 8 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 9 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 10 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 11 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 12 | golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 13 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 14 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 15 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 16 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 17 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 18 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 19 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 20 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 21 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 22 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 23 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 24 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 25 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 26 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 27 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 28 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 29 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 30 | golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 31 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 32 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 33 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 34 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 35 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 36 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 37 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 38 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 39 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 40 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 41 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 42 | golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 43 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 44 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 45 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 46 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 47 | golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= 48 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 49 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 50 | golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 51 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 52 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 53 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 54 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 55 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 56 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 57 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 58 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 59 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 60 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 61 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 62 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 63 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 64 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 65 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 66 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 67 | -------------------------------------------------------------------------------- /tests/pages/two-page-setter.test.js: -------------------------------------------------------------------------------- 1 | import 'mocha'; 2 | import { expect } from 'chai'; 3 | import { FitMode } from '../../code/book-viewer-types.js'; 4 | import { TwoPageSetter } from '../../code/pages/two-page-setter.js'; 5 | 6 | /** @typedef {import('../../code/book-viewer-types.js').Box} Box */ 7 | /** @typedef {import('../../code/book-viewer-types.js').PageLayoutParams} PageLayoutParams */ 8 | /** @typedef {import('../../code/book-viewer-types.js').PageSetting} PageSetting */ 9 | 10 | describe('TwoPageSetter', () => { 11 | /** @type {PageLayoutParams} */ 12 | let layoutParams; 13 | 14 | /** @type {TwoPageSetter} */ 15 | let setter; 16 | 17 | beforeEach(() => { 18 | layoutParams = {}; 19 | setter = new TwoPageSetter(); 20 | }); 21 | 22 | describe('FitMode.Width', () => { 23 | beforeEach(() => { 24 | layoutParams.fitMode = FitMode.Width; 25 | }); 26 | 27 | describe('no rotation', () => { 28 | beforeEach(() => { 29 | layoutParams.rotateTimes = 0; 30 | }); 31 | 32 | it(`sizes page properly when par < bvar`, () => { 33 | const PAGE_ASPECT_RATIO = 0.5; 34 | const BV_WIDTH = 400; 35 | const BV_HEIGHT = 200; 36 | layoutParams.pageAspectRatio = PAGE_ASPECT_RATIO; 37 | layoutParams.bv = { left: 0, top: 0, width: BV_WIDTH, height: BV_HEIGHT }; 38 | 39 | /** @type {PageSetting} */ 40 | const pageSetting = setter.updateLayout(layoutParams); 41 | expect(pageSetting.boxes).to.be.an('array'); 42 | expect(pageSetting.boxes).to.have.lengthOf(2); 43 | 44 | /** @type {Box} */ 45 | const box1 = pageSetting.boxes[0]; 46 | expect(box1.left).equals(0); 47 | expect(box1.top).equals(0); 48 | expect(box1.width).equals(BV_WIDTH / 2); 49 | expect(box1.height).equals(BV_WIDTH / 2 / PAGE_ASPECT_RATIO); 50 | 51 | /** @type {Box} */ 52 | const box2 = pageSetting.boxes[0]; 53 | expect(box2.left).equals(0); 54 | expect(box2.top).equals(0); 55 | expect(box2.width).equals(BV_WIDTH / 2); 56 | expect(box2.height).equals(BV_WIDTH / 2 / PAGE_ASPECT_RATIO); 57 | }); 58 | 59 | it(`centers page vertically when par > bvar`, () => { 60 | const PAGE_ASPECT_RATIO = 0.5; 61 | const BV_WIDTH = 400; 62 | const BV_HEIGHT = 1200; 63 | layoutParams.pageAspectRatio = PAGE_ASPECT_RATIO; 64 | layoutParams.bv = { left: 0, top: 0, width: BV_WIDTH, height: BV_HEIGHT }; 65 | 66 | /** @type {PageSetting} */ 67 | const pageSetting = setter.updateLayout(layoutParams); 68 | expect(pageSetting.boxes).to.be.an('array'); 69 | expect(pageSetting.boxes).to.have.lengthOf(2); 70 | 71 | /** @type {Box} */ 72 | const box1 = pageSetting.boxes[0]; 73 | expect(box1.width).equals(BV_WIDTH / 2); 74 | expect(box1.height).equals(BV_WIDTH / 2 / PAGE_ASPECT_RATIO); 75 | expect(box1.left).equals(0); 76 | expect(box1.top).equals((BV_HEIGHT - BV_WIDTH / 2 / PAGE_ASPECT_RATIO) / 2); 77 | 78 | /** @type {Box} */ 79 | const box2 = pageSetting.boxes[1]; 80 | expect(box2.width).equals(BV_WIDTH / 2); 81 | expect(box2.height).equals(BV_WIDTH / 2 / PAGE_ASPECT_RATIO); 82 | expect(box2.left).equals(BV_WIDTH / 2); 83 | expect(box2.top).equals((BV_HEIGHT - BV_WIDTH / 2 / PAGE_ASPECT_RATIO) / 2); 84 | }); 85 | }); 86 | }); 87 | 88 | describe('FitMode.Height', () => { 89 | beforeEach(() => { 90 | layoutParams.fitMode = FitMode.Height; 91 | }); 92 | 93 | describe('no rotation', () => { 94 | beforeEach(() => { 95 | layoutParams.rotateTimes = 0; 96 | }); 97 | 98 | it(`sizes page properly when par > bvar`, () => { 99 | const PAGE_ASPECT_RATIO = 2; 100 | const BV_WIDTH = 200; 101 | const BV_HEIGHT = 400; 102 | layoutParams.pageAspectRatio = PAGE_ASPECT_RATIO; 103 | layoutParams.bv = { left: 0, top: 0, width: BV_WIDTH, height: BV_HEIGHT }; 104 | 105 | /** @type {PageSetting} */ 106 | const pageSetting = setter.updateLayout(layoutParams); 107 | expect(pageSetting.boxes).to.be.an('array'); 108 | expect(pageSetting.boxes).to.have.lengthOf(2); 109 | 110 | /** @type {Box} */ 111 | const box1 = pageSetting.boxes[0]; 112 | expect(box1.top).equals(0); 113 | expect(box1.height).equals(BV_HEIGHT); 114 | expect(box1.left).equals(0); 115 | expect(box1.width).equals(BV_HEIGHT * PAGE_ASPECT_RATIO); 116 | 117 | const box2 = pageSetting.boxes[1]; 118 | expect(box2.top).equals(0); 119 | expect(box2.height).equals(BV_HEIGHT); 120 | expect(box2.left).equals(BV_HEIGHT * PAGE_ASPECT_RATIO); 121 | expect(box2.width).equals(BV_HEIGHT * PAGE_ASPECT_RATIO); 122 | 123 | // Width of the book viewer should have been changed. 124 | expect(pageSetting.bv.width).equals(BV_HEIGHT * PAGE_ASPECT_RATIO * 2); 125 | }); 126 | 127 | it(`centers horizontally when par < bvar`, () => { 128 | const PAGE_ASPECT_RATIO = 0.5; 129 | const BV_WIDTH = 400; 130 | const BV_HEIGHT = 400; 131 | layoutParams.pageAspectRatio = PAGE_ASPECT_RATIO; 132 | layoutParams.bv = { left: 0, top: 0, width: BV_WIDTH, height: BV_HEIGHT }; 133 | 134 | /** @type {PageSetting} */ 135 | const pageSetting = setter.updateLayout(layoutParams); 136 | expect(pageSetting.boxes).to.be.an('array'); 137 | expect(pageSetting.boxes).to.have.lengthOf(2); 138 | 139 | /** @type {Box} */ 140 | const box1 = pageSetting.boxes[0]; 141 | expect(box1.top).equals(0); 142 | expect(box1.height).equals(BV_HEIGHT); 143 | expect(box1.width).equals(BV_HEIGHT * PAGE_ASPECT_RATIO); 144 | expect(box1.left).equals(0); 145 | 146 | /** @type {Box} */ 147 | const box2 = pageSetting.boxes[1]; 148 | expect(box2.top).equals(0); 149 | expect(box2.height).equals(BV_HEIGHT); 150 | expect(box2.width).equals(BV_HEIGHT * PAGE_ASPECT_RATIO); 151 | expect(box2.left).equals(BV_HEIGHT * PAGE_ASPECT_RATIO); 152 | }); 153 | }); 154 | }); 155 | }); 156 | -------------------------------------------------------------------------------- /code/bitjs/file/sniffer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * File Sniffer. 3 | * Makes an attempt to resolve a byte stream into a MIME type. 4 | * 5 | * Licensed under the MIT License 6 | * 7 | * Copyright(c) 2020 Google Inc. 8 | */ 9 | 10 | // https://mimesniff.spec.whatwg.org/ is a good resource. 11 | // Easy targets for reverse-engineering: 12 | // - https://github.com/h2non/filetype 13 | // - https://github.com/gabriel-vasile/mimetype (particularly internal/magic/ftyp.go) 14 | 15 | // NOTE: Because the ICO format also starts with a couple zero bytes, this tree will rely on the 16 | // File Type box never going beyond 255 bytes in length which, seems unlikely according to 17 | // https://dev.to/alfg/a-quick-dive-into-mp4-57fo. 18 | // 'ISO-BMFF': [[0x00, 0x00, 0x00, '??', 0x66, 0x74, 0x79, 0x70]], // box_length, then 'ftyp' 19 | // 'MATROSKA': [[0x1A, 0x45, 0xDF, 0xA3]] 20 | 21 | // A subset of "modern" formats from https://en.wikipedia.org/wiki/List_of_file_signatures. 22 | // Mapping of MIME type to magic numbers. Each file type can have multiple signatures. 23 | // '??' is used as a placeholder value. 24 | const fileSignatures = { 25 | // Document formats. 26 | 'application/pdf': [[0x25, 0x50, 0x44, 0x46, 0x2d]], // '%PDF-' 27 | 28 | // Archive formats: 29 | 'application/gzip': [[0x1F, 0x8B, 0x08]], 30 | 'application/x-tar': [ // 'ustar' 31 | [0x75, 0x73, 0x74, 0x61, 0x72, 0x00, 0x30, 0x30], 32 | [0x75, 0x73, 0x74, 0x61, 0x72, 0x20, 0x20, 0x00], 33 | ], 34 | 'application/x-7z-compressed': [[0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C]], // '7z' 35 | 'application/x-bzip2': [[0x42, 0x5A, 0x68]], // 'BZh' 36 | 'application/x-rar-compressed': [[0x52, 0x61, 0x72, 0x21, 0x1A, 0x07]], // 'Rar!' 37 | 'application/zip': [ // 'PK' 38 | [0x50, 0x4B, 0x03, 0x04], 39 | [0x50, 0x4B, 0x05, 0x06], 40 | [0x50, 0x4B, 0x07, 0x08], 41 | ], 42 | 43 | // Image formats. 44 | 'image/bmp': [[0x42, 0x4D]], // 'BM' 45 | 'image/gif': [[0x47, 0x49, 0x46, 0x38]], // 'GIF8' 46 | 'image/jpeg': [[0xFF, 0xD8, 0xFF]], 47 | 'image/png': [[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]], 48 | 'image/webp': [[0x52, 0x49, 0x46, 0x46, '??', '??', '??', '??', 0x57, 0x45, 0x42, 0x50]], // 'RIFF....WEBP' 49 | 'image/x-icon': [[0x00, 0x00, 0x01, 0x00]], 50 | 51 | // Audio/Video formats. 52 | 'application/ogg': [[0x4F, 0x67, 0x67, 0x53]], // 'OggS' 53 | 'audio/flac': [[0x66, 0x4C, 0x61, 0x43]], // 'fLaC' 54 | 'audio/mpeg': [ 55 | [0xFF, 0xFB], 56 | [0xFF, 0xF3], 57 | [0xFF, 0xF2], 58 | [0x49, 0x44, 0x33], // 'ID3' 59 | ], 60 | 'audio/wav': [[0x52, 0x49, 0x46, 0x46, '??', '??', '??', '??', 0x57, 0x41, 0x56, 0x45]], // 'RIFF....WAVE' 61 | 'video/avi': [[0x52, 0x49, 0x46, 0x46, '??', '??', '??', '??', 0x41, 0x56, 0x49, 0x20]], // 'RIFF....AVI ' 62 | 63 | // Miscellaneous. 64 | 'font/woff': [[0x77, 0x4F, 0x46, 0x46]], // 'wOFF' 65 | 'font/woff2': [[0x77, 0x4F, 0x46, 0x32]], // 'wOF2' 66 | }; 67 | 68 | /** 69 | * Represents a single byte in the magic number tree. If this node terminates a known MIME type 70 | * (see magic numbers above), then the mimeType field will be set. 71 | */ 72 | class Node { 73 | /** @type {string} */ 74 | mimeType; 75 | 76 | /** @type {Object} */ 77 | children = {}; 78 | 79 | /** @param {number} value The byte that this Node points at. */ 80 | constructor(value) { 81 | /** @type {number} */ 82 | this.value = value; 83 | } 84 | } 85 | 86 | /** Top-level node in the byte tree. */ 87 | let root = null; 88 | /** The maximum depth of the byte tree. */ 89 | let maxDepth = 0; 90 | 91 | /** 92 | * This function initializes the byte tree. It is lazily called upon findMimeType(), but if you care 93 | * about when the tree initializes (like in startup, etc), you can call it yourself here. 94 | */ 95 | export function initialize() { 96 | root = new Node(); 97 | 98 | // Construct the tree, erroring if overlapping mime types are found. 99 | for (const mimeType in fileSignatures) { 100 | for (const signature of fileSignatures[mimeType]) { 101 | let curNode = root; 102 | let depth = 0; 103 | for (const byte of signature) { 104 | if (curNode.children[byte] === undefined) { 105 | if (byte === '??' && !curNode.children['??'] && Object.keys(curNode.children).length > 0) { 106 | // Reset the byte tree, it is bogus. 107 | root = null; 108 | throw 'Cannot add a placeholder child to a node that has non-placeholder children'; 109 | } else if (byte !== '??' && curNode.children['??']) { 110 | // Reset the byte tree, it is bogus. 111 | root = null; 112 | throw 'Cannot add a non-placeholder child to a node that has a placeholder child'; 113 | } 114 | curNode.children[byte] = new Node(byte); 115 | } 116 | depth++; 117 | curNode = curNode.children[byte]; 118 | } // for each byte 119 | 120 | if (maxDepth < depth) { 121 | maxDepth = depth; 122 | } 123 | 124 | if (curNode.mimeType) { 125 | throw `Magic number collision: ${curNode.mimeType} overlaps with ${mimeType}`; 126 | } else if (Object.keys(curNode.children).length > 0) { 127 | throw `${mimeType} magic number is not unique, it collides with other mime types`; 128 | } 129 | curNode.mimeType = mimeType; 130 | } // for each signature 131 | } 132 | } 133 | 134 | /** 135 | * Finds the likely MIME type represented by the ArrayBuffer. 136 | * @param {ArrayBuffer} ab 137 | * @returns {string} The MIME type of the buffer, or undefined. 138 | */ 139 | export function findMimeType(ab) { 140 | if (!root) { 141 | initialize(); 142 | } 143 | 144 | const arr = new Uint8Array(ab); 145 | let curNode = root; 146 | let mimeType; 147 | // Step through bytes, updating curNode as it walks down the byte tree. 148 | for (const byte of arr) { 149 | // If we found the mimeType or it is unknown, break the loop. 150 | if (!curNode || (mimeType = curNode.mimeType)) { 151 | break; 152 | } 153 | // Move into the next byte's node (if it exists) or the placeholder node (if it exists). 154 | curNode = curNode.children[byte] || curNode.children['??']; 155 | } 156 | return mimeType; 157 | } 158 | -------------------------------------------------------------------------------- /code/comics/comic-book-binder.js: -------------------------------------------------------------------------------- 1 | /** 2 | * comic-book-binder.js 3 | * 4 | * Licensed under the MIT License 5 | * 6 | * Copyright(c) 2019 Google Inc. 7 | */ 8 | 9 | import { UnarchiveEventType } from '../bitjs/archive/decompress.js'; 10 | import { BookBinder, BookType } from '../book-binder.js'; 11 | import { BookBindingCompleteEvent, BookMetadataXmlExtractedEvent, BookPageExtractedEvent, BookProgressEvent } from '../book-events.js'; 12 | import { createPageFromFileAsync, guessMimeType } from '../page.js'; 13 | import { sortPages } from './comic-book-page-sorter.js'; 14 | import { Params } from '../common/helpers.js'; 15 | import { createMetadataFromComicBookXml } from '../metadata/book-metadata.js'; 16 | 17 | const STREAM_OPTIMIZED_NS = 'http://www.codedread.com/sop'; 18 | 19 | /** 20 | * The default BookBinder used in kthoom. It takes each extracted file from the Unarchiver and 21 | * turns that directly into a Page for the comic book. 22 | */ 23 | export class ComicBookBinder extends BookBinder { 24 | constructor(filenameOrUri, ab, totalExpectedSize) { 25 | super(filenameOrUri, ab, totalExpectedSize); 26 | 27 | /** @private {string} */ 28 | this.mimeType_ = null; 29 | 30 | // As each file becomes available from the Unarchiver, we kick off an async operation 31 | // to construct a Page object. After all pages are retrieved, we sort and then extract them. 32 | // (Or, if the book is stream-optimized, we extract them in order immediately) 33 | /** @private {Promise} */ 34 | this.pagePromises_ = []; 35 | 36 | /** @private {boolean} */ 37 | this.optimizedForStreaming_ = (Params.alwaysOptimizedForStreaming === 'true') || false; 38 | } 39 | 40 | /** @override */ 41 | beforeStart_() { 42 | let prevExtractPromise = Promise.resolve(true); 43 | this.unarchiver.addEventListener(UnarchiveEventType.EXTRACT, evt => { 44 | // Convert each unarchived file into a Page. 45 | // TODO: Error if not present? 46 | if (evt.unarchivedFile) { 47 | const filename = evt.unarchivedFile.filename; 48 | const mimeType = guessMimeType(filename) || ''; 49 | if (mimeType.startsWith('image/')) { 50 | const pagePromise = createPageFromFileAsync(evt.unarchivedFile); 51 | // TODO: Error if we have more pages than totalPages_. 52 | this.pagePromises_.push(pagePromise); 53 | 54 | if (this.optimizedForStreaming_) { 55 | const numPages = this.pagePromises_.length; 56 | prevExtractPromise = prevExtractPromise.then(() => { 57 | return pagePromise.then(page => { 58 | this.dispatchEvent(new BookPageExtractedEvent(this, page, numPages)); 59 | }); 60 | }); 61 | } 62 | } 63 | // Extract metadata, if found. 64 | else if (filename.toLowerCase() === 'comicinfo.xml') { 65 | const metadataXml = new TextDecoder().decode(evt.unarchivedFile.fileData); 66 | if (metadataXml) { 67 | const bookMetadata = createMetadataFromComicBookXml(metadataXml); 68 | this.dispatchEvent(new BookMetadataXmlExtractedEvent(this, bookMetadata)); 69 | 70 | // If this is the first file extracted and it says the archive is optimized for 71 | // streaming, then we will emit page extracted events as they are extracted instead 72 | // of upon all files being extracted to display the first page as fast as possible. 73 | if (this.pagePromises_.length === 0 && bookMetadata.isOptimizedForStreaming()) { 74 | this.optimizedForStreaming_ = true; 75 | } 76 | } 77 | } 78 | 79 | // Emit a Progress event for each unarchived file. 80 | this.dispatchEvent(new BookProgressEvent(this, this.pagePromises_.length)); 81 | } 82 | }); 83 | this.unarchiver.addEventListener(UnarchiveEventType.FINISH, evt => { 84 | this.setUnarchiveComplete(); 85 | 86 | if (evt.metadata.comment && Params.metadata) { 87 | alert(evt.metadata.comment); 88 | } 89 | let pages = []; 90 | let foundError = false; 91 | let pagePromiseChain = Promise.resolve(true); 92 | for (let pageNum = 0; pageNum < this.pagePromises_.length; ++pageNum) { 93 | pagePromiseChain = pagePromiseChain.then(() => { 94 | return this.pagePromises_[pageNum] 95 | .then(page => pages.push(page)) 96 | .catch(e => { 97 | console.error(`Error creating page: ${e}`); 98 | foundError = true; 99 | }) 100 | .finally(() => true); 101 | }); 102 | } 103 | 104 | pagePromiseChain.then(() => { 105 | console.log(` number of pages = ${pages.length}`); 106 | 107 | if (foundError) { 108 | // TODO: Better error handling. 109 | alert('Some pages had errors. See the console for more info.') 110 | } 111 | 112 | // Sort the book's pages, if this book was not optimized for streaming. 113 | if (!this.optimizedForStreaming_) { 114 | pages = pages.slice(0).sort((a, b) => sortPages(a, b)); 115 | 116 | // Emit an extract event for each page in its proper order. 117 | for (let i = 0; i < pages.length; ++i) { 118 | this.dispatchEvent(new BookPageExtractedEvent(this, pages[i], i + 1)); 119 | } 120 | } 121 | 122 | // Emit a complete event. 123 | this.dispatchEvent(new BookBindingCompleteEvent(this)); 124 | }); 125 | 126 | this.stop(); 127 | }); 128 | 129 | switch (this.unarchiver.getMIMEType()) { 130 | case 'application/zip': 131 | this.mimeType_ = 'application/vnd.comicbook+zip'; 132 | break; 133 | case 'application/x-rar-compressed': 134 | this.mimeType_ ='application/vnd.comicbook-rar'; 135 | break; 136 | case 'application/x-tar': 137 | this.mimeType_ = 'application/x-cbt'; 138 | break; 139 | default: throw 'Unknown comic book archive type'; 140 | } 141 | } 142 | 143 | getBookType() { return BookType.COMIC; } 144 | 145 | getMIMEType() { 146 | return this.mimeType_; 147 | } 148 | 149 | /** @override */ 150 | getLayoutPercentage() { return this.getUnarchivingPercentage() * this.getUnarchivingPercentage(); } 151 | } 152 | -------------------------------------------------------------------------------- /code/bitjs/io/bitbuffer.js: -------------------------------------------------------------------------------- 1 | /* 2 | * bitbuffer.js 3 | * 4 | * Provides writer for bits. 5 | * 6 | * Licensed under the MIT License 7 | * 8 | * Copyright(c) 2023 Google Inc. 9 | * Copyright(c) 2011 antimatter15 10 | */ 11 | 12 | const BITMASK = [ 13 | 0, 14 | 0b00000001, 15 | 0b00000011, 16 | 0b00000111, 17 | 0b00001111, 18 | 0b00011111, 19 | 0b00111111, 20 | 0b01111111, 21 | 0b11111111, 22 | ] 23 | 24 | /** 25 | * A write-only Bit buffer which uses a Uint8Array as a backing store. 26 | */ 27 | export class BitBuffer { 28 | /** 29 | * @param {number} numBytes The number of bytes to allocate. 30 | * @param {boolean} mtl The bit-packing mode. True means pack bits from most-significant (7) to 31 | * least-significant (0). Defaults false: least-significant (0) to most-significant (8). 32 | */ 33 | constructor(numBytes, mtl = false) { 34 | if (typeof numBytes != typeof 1 || numBytes <= 0) { 35 | throw "Error! BitBuffer initialized with '" + numBytes + "'"; 36 | } 37 | 38 | /** 39 | * @type {Uint8Array} 40 | * @public 41 | */ 42 | this.data = new Uint8Array(numBytes); 43 | 44 | /** 45 | * Whether we pack bits from most-significant-bit to least. Defaults false (least-to-most 46 | * significant bit packing). 47 | * @type {boolean} 48 | * @private 49 | */ 50 | this.mtl = mtl; 51 | 52 | /** 53 | * The current byte we are filling with bits. 54 | * @type {number} 55 | * @public 56 | */ 57 | this.bytePtr = 0; 58 | 59 | /** 60 | * Points at the bit within the current byte where the next bit will go. This number ranges 61 | * from 0 to 7 and the direction of packing is indicated by the mtl property. 62 | * @type {number} 63 | * @public 64 | */ 65 | this.bitPtr = this.mtl ? 7 : 0; 66 | } 67 | 68 | // TODO: Be consistent with naming across classes (big-endian and little-endian). 69 | 70 | /** @returns {boolean} */ 71 | getPackingDirection() { 72 | return this.mtl; 73 | } 74 | 75 | /** 76 | * Sets the bit-packing direction. Default (false) is least-significant-bit (0) to 77 | * most-significant (7). Changing the bit-packing direction when the bit pointer is in the 78 | * middle of a byte will fill the rest of that byte with 0s using the current bit-packing 79 | * direction and then set the bit pointer to the appropriate bit of the next byte. If there 80 | * are no more bytes left in this buffer, it will throw an error. 81 | */ 82 | setPackingDirection(mtl = false) { 83 | if (this.mtl !== mtl) { 84 | if (this.mtl && this.bitPtr !== 7) { 85 | this.bytePtr++; 86 | if (this.bytePtr >= this.data.byteLength) { 87 | throw `No more bytes left when switching packing direction`; 88 | } 89 | this.bitPtr = 0; 90 | } else if (!this.mtl && this.bitPtr !== 0) { 91 | this.bytePtr++; 92 | if (this.bytePtr >= this.data.byteLength) { 93 | throw `No more bytes left when switching packing direction`; 94 | } 95 | this.bitPtr = 7; 96 | } 97 | } 98 | 99 | this.mtl = mtl; 100 | } 101 | 102 | /** 103 | * writeBits(3, 6) is the same as writeBits(0b000011, 6). 104 | * Will throw an error (without writing) if this would over-flow the buffer. 105 | * @param {number} val The bits to pack into the buffer. Negative values are not allowed. 106 | * @param {number} numBits Must be positive, non-zero and less or equal to than 53, since 107 | * JavaScript can only support 53-bit integers. 108 | */ 109 | writeBits(val, numBits) { 110 | if (val < 0 || typeof val !== typeof 1) { 111 | throw `Trying to write an invalid value into the BitBuffer: ${val}`; 112 | } 113 | if (numBits < 0 || numBits > 53) { 114 | throw `Trying to write ${numBits} bits into the BitBuffer`; 115 | } 116 | 117 | const totalBitsInBuffer = this.data.byteLength * 8; 118 | const writtenBits = this.bytePtr * 8 + this.bitPtr; 119 | const bitsLeftInBuffer = totalBitsInBuffer - writtenBits; 120 | if (numBits > bitsLeftInBuffer) { 121 | throw `Trying to write ${numBits} into the BitBuffer that only has ${bitsLeftInBuffer}`; 122 | } 123 | 124 | // Least-to-most-significant bit packing method (LTM). 125 | if (!this.mtl) { 126 | let numBitsLeftToWrite = numBits; 127 | while (numBitsLeftToWrite > 0) { 128 | /** The number of bits available to fill in this byte. */ 129 | const bitCapacityInThisByte = 8 - this.bitPtr; 130 | /** The number of bits of val we will write into this byte. */ 131 | const numBitsToWriteIntoThisByte = Math.min(numBitsLeftToWrite, bitCapacityInThisByte); 132 | /** The number of bits that fit in subsequent bytes. */ 133 | const numExcessBits = numBitsLeftToWrite - numBitsToWriteIntoThisByte; 134 | if (numExcessBits < 0) { 135 | throw `Error in LTM bit packing, # of excess bits is negative`; 136 | } 137 | /** The actual bits that need to be written into this byte. Starts at LSB. */ 138 | let actualBitsToWrite = (val & BITMASK[numBitsToWriteIntoThisByte]); 139 | // Only adjust and write bits if any are set to 1. 140 | if (actualBitsToWrite > 0) { 141 | actualBitsToWrite <<= this.bitPtr; 142 | // Now write into the buffer. 143 | this.data[this.bytePtr] |= actualBitsToWrite; 144 | } 145 | // Update the bit/byte pointers and remaining bits to write. 146 | this.bitPtr += numBitsToWriteIntoThisByte; 147 | if (this.bitPtr > 7) { 148 | if (this.bitPtr !== 8) { 149 | throw `Error in LTM bit packing. Tried to write more bits than it should have.`; 150 | } 151 | this.bytePtr++; 152 | this.bitPtr = 0; 153 | } 154 | // Remove bits that have been written from LSB end. 155 | val >>= numBitsToWriteIntoThisByte; 156 | numBitsLeftToWrite -= numBitsToWriteIntoThisByte; 157 | } 158 | } 159 | // Most-to-least-significant bit packing method (MTL). 160 | else { 161 | let numBitsLeftToWrite = numBits; 162 | while (numBitsLeftToWrite > 0) { 163 | /** The number of bits available to fill in this byte. */ 164 | const bitCapacityInThisByte = this.bitPtr + 1; 165 | /** The number of bits of val we will write into this byte. */ 166 | const numBitsToWriteIntoThisByte = Math.min(numBitsLeftToWrite, bitCapacityInThisByte); 167 | /** The number of bits that fit in subsequent bytes. */ 168 | const numExcessBits = numBitsLeftToWrite - numBitsToWriteIntoThisByte; 169 | if (numExcessBits < 0) { 170 | throw `Error in MTL bit packing, # of excess bits is negative`; 171 | } 172 | /** The actual bits that need to be written into this byte. Starts at MSB. */ 173 | let actualBitsToWrite = ((val >> numExcessBits) & BITMASK[numBitsToWriteIntoThisByte]); 174 | // Only adjust and write bits if any are set to 1. 175 | if (actualBitsToWrite > 0) { 176 | // If the number of bits left to write do not fill up this byte, we need to shift these 177 | // bits to the left so they are written into the proper place in the buffer. 178 | if (numBitsLeftToWrite < bitCapacityInThisByte) { 179 | actualBitsToWrite <<= (bitCapacityInThisByte - numBitsLeftToWrite); 180 | } 181 | // Now write into the buffer. 182 | this.data[this.bytePtr] |= actualBitsToWrite; 183 | } 184 | // Update the bit/byte pointers and remaining bits to write 185 | this.bitPtr -= numBitsToWriteIntoThisByte; 186 | if (this.bitPtr < 0) { 187 | if (this.bitPtr !== -1) { 188 | throw `Error in MTL bit packing. Tried to write more bits than it should have.`; 189 | } 190 | this.bytePtr++; 191 | this.bitPtr = 7; 192 | } 193 | // Remove bits that have been written from MSB end. 194 | val -= (actualBitsToWrite << numExcessBits); 195 | numBitsLeftToWrite -= numBitsToWriteIntoThisByte; 196 | } 197 | } 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /code/bitjs/archive/untar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * untar.js 3 | * 4 | * Licensed under the MIT License 5 | * 6 | * Copyright(c) 2011 Google Inc. 7 | * 8 | * Reference Documentation: 9 | * 10 | * TAR format: http://www.gnu.org/software/automake/manual/tar/Standard.html 11 | */ 12 | 13 | import { ByteStream } from '../io/bytestream.js'; 14 | 15 | const UnarchiveState = { 16 | NOT_STARTED: 0, 17 | UNARCHIVING: 1, 18 | WAITING: 2, 19 | FINISHED: 3, 20 | }; 21 | 22 | /** @type {MessagePort} */ 23 | let hostPort; 24 | 25 | // State - consider putting these into a class. 26 | let unarchiveState = UnarchiveState.NOT_STARTED; 27 | /** @type {ByteStream} */ 28 | let bytestream = null; 29 | let allLocalFiles = null; 30 | let logToConsole = false; 31 | 32 | // Progress variables. 33 | let currentFilename = ''; 34 | let currentFileNumber = 0; 35 | let currentBytesUnarchivedInFile = 0; 36 | let currentBytesUnarchived = 0; 37 | let totalUncompressedBytesInArchive = 0; 38 | let totalFilesInArchive = 0; 39 | 40 | // Helper functions. 41 | const info = function (str) { 42 | hostPort.postMessage({ type: 'info', msg: str }); 43 | }; 44 | const err = function (str) { 45 | hostPort.postMessage({ type: 'error', msg: str }); 46 | }; 47 | const postProgress = function () { 48 | hostPort.postMessage({ 49 | type: 'progress', 50 | currentFilename, 51 | currentFileNumber, 52 | currentBytesUnarchivedInFile, 53 | currentBytesUnarchived, 54 | totalUncompressedBytesInArchive, 55 | totalFilesInArchive, 56 | totalCompressedBytesRead: bytestream.getNumBytesRead(), 57 | }); 58 | }; 59 | 60 | // Removes all characters from the first zero-byte in the string onwards. 61 | const readCleanString = function (bstr, numBytes) { 62 | const str = bstr.readString(numBytes); 63 | const zIndex = str.indexOf(String.fromCharCode(0)); 64 | return zIndex != -1 ? str.substr(0, zIndex) : str; 65 | }; 66 | 67 | class TarLocalFile { 68 | // takes a ByteStream and parses out the local file information 69 | constructor(bstream) { 70 | this.isValid = false; 71 | 72 | let bytesRead = 0; 73 | 74 | // Read in the header block 75 | this.name = readCleanString(bstream, 100); 76 | this.mode = readCleanString(bstream, 8); 77 | this.uid = readCleanString(bstream, 8); 78 | this.gid = readCleanString(bstream, 8); 79 | this.size = parseInt(readCleanString(bstream, 12), 8); 80 | this.mtime = readCleanString(bstream, 12); 81 | this.chksum = readCleanString(bstream, 8); 82 | this.typeflag = readCleanString(bstream, 1); 83 | this.linkname = readCleanString(bstream, 100); 84 | this.maybeMagic = readCleanString(bstream, 6); 85 | 86 | if (this.maybeMagic == 'ustar') { 87 | this.version = readCleanString(bstream, 2); 88 | this.uname = readCleanString(bstream, 32); 89 | this.gname = readCleanString(bstream, 32); 90 | this.devmajor = readCleanString(bstream, 8); 91 | this.devminor = readCleanString(bstream, 8); 92 | this.prefix = readCleanString(bstream, 155); 93 | 94 | // From https://linux.die.net/man/1/ustar: 95 | // "The name field (100 chars) an inserted slash ('/') and the prefix field (155 chars) 96 | // produce the pathname of the file. When recreating the original filename, name and prefix 97 | // are concatenated, using a slash character in the middle. If a pathname does not fit in the 98 | // space provided or may not be split at a slash character so that the parts will fit into 99 | // 100 + 155 chars, the file may not be archived. Linknames longer than 100 chars may not be 100 | // archived too." 101 | if (this.prefix.length) { 102 | this.name = `${this.prefix}/${this.name}`; 103 | } 104 | bstream.readBytes(12); // 512 - 500 105 | } else { 106 | bstream.readBytes(255); // 512 - 257 107 | } 108 | 109 | bytesRead += 512; 110 | 111 | // Done header, now rest of blocks are the file contents. 112 | this.filename = this.name; 113 | /** @type {Uint8Array} */ 114 | this.fileData = null; 115 | 116 | info(`Untarring file '${this.filename}'`); 117 | info(` size = ${this.size}`); 118 | info(` typeflag = ${this.typeflag}`); 119 | 120 | // A regular file. 121 | if (this.typeflag == 0) { 122 | info(' This is a regular file.'); 123 | const sizeInBytes = parseInt(this.size); 124 | this.fileData = new Uint8Array(bstream.readBytes(sizeInBytes)); 125 | bytesRead += sizeInBytes; 126 | if (this.name.length > 0 && this.size > 0 && this.fileData && this.fileData.buffer) { 127 | this.isValid = true; 128 | } 129 | 130 | // Round up to 512-byte blocks. 131 | const remaining = 512 - bytesRead % 512; 132 | if (remaining > 0 && remaining < 512) { 133 | bstream.readBytes(remaining); 134 | } 135 | } else if (this.typeflag == 5) { 136 | info(' This is a directory.') 137 | } 138 | } 139 | } 140 | 141 | const untar = function () { 142 | let bstream = bytestream.tee(); 143 | 144 | // While we don't encounter an empty block, keep making TarLocalFiles. 145 | while (bstream.peekNumber(4) != 0) { 146 | const oneLocalFile = new TarLocalFile(bstream); 147 | if (oneLocalFile && oneLocalFile.isValid) { 148 | // If we make it to this point and haven't thrown an error, we have successfully 149 | // read in the data for a local file, so we can update the actual bytestream. 150 | bytestream = bstream.tee(); 151 | 152 | allLocalFiles.push(oneLocalFile); 153 | totalUncompressedBytesInArchive += oneLocalFile.size; 154 | 155 | // update progress 156 | currentFilename = oneLocalFile.filename; 157 | currentFileNumber = totalFilesInArchive++; 158 | currentBytesUnarchivedInFile = oneLocalFile.size; 159 | currentBytesUnarchived += oneLocalFile.size; 160 | hostPort.postMessage({ type: 'extract', unarchivedFile: oneLocalFile }, [oneLocalFile.fileData.buffer]); 161 | postProgress(); 162 | } 163 | } 164 | totalFilesInArchive = allLocalFiles.length; 165 | 166 | postProgress(); 167 | 168 | bytestream = bstream.tee(); 169 | }; 170 | 171 | // event.data.file has the first ArrayBuffer. 172 | // event.data.bytes has all subsequent ArrayBuffers. 173 | const onmessage = function (event) { 174 | const bytes = event.data.file || event.data.bytes; 175 | logToConsole = !!event.data.logToConsole; 176 | 177 | // This is the very first time we have been called. Initialize the bytestream. 178 | if (!bytestream) { 179 | bytestream = new ByteStream(bytes); 180 | } else { 181 | bytestream.push(bytes); 182 | } 183 | 184 | if (unarchiveState === UnarchiveState.NOT_STARTED) { 185 | currentFilename = ''; 186 | currentFileNumber = 0; 187 | currentBytesUnarchivedInFile = 0; 188 | currentBytesUnarchived = 0; 189 | totalUncompressedBytesInArchive = 0; 190 | totalFilesInArchive = 0; 191 | allLocalFiles = []; 192 | 193 | hostPort.postMessage({ type: 'start' }); 194 | 195 | unarchiveState = UnarchiveState.UNARCHIVING; 196 | 197 | postProgress(); 198 | } 199 | 200 | if (unarchiveState === UnarchiveState.UNARCHIVING || 201 | unarchiveState === UnarchiveState.WAITING) { 202 | try { 203 | untar(); 204 | unarchiveState = UnarchiveState.FINISHED; 205 | hostPort.postMessage({ type: 'finish', metadata: {} }); 206 | } catch (e) { 207 | if (typeof e === 'string' && e.startsWith('Error! Overflowed')) { 208 | // Overrun the buffer. 209 | unarchiveState = UnarchiveState.WAITING; 210 | } else { 211 | console.error('Found an error while untarring'); 212 | console.dir(e); 213 | throw e; 214 | } 215 | } 216 | } 217 | }; 218 | 219 | /** 220 | * Connect the host to the untar implementation with the given MessagePort. 221 | * @param {MessagePort} port 222 | */ 223 | export function connect(port) { 224 | if (hostPort) { 225 | throw `hostPort already connected in untar.js`; 226 | } 227 | hostPort = port; 228 | port.onmessage = onmessage; 229 | } 230 | 231 | export function disconnect() { 232 | if (!hostPort) { 233 | throw `hostPort was not connected in unzip.js`; 234 | } 235 | 236 | hostPort = null; 237 | 238 | unarchiveState = UnarchiveState.NOT_STARTED; 239 | bytestream = null; 240 | allLocalFiles = null; 241 | logToConsole = false; 242 | 243 | currentFilename = ''; 244 | currentFileNumber = 0; 245 | currentBytesUnarchivedInFile = 0; 246 | currentBytesUnarchived = 0; 247 | totalUncompressedBytesInArchive = 0; 248 | totalFilesInArchive = 0; 249 | } 250 | -------------------------------------------------------------------------------- /code/book-binder.js: -------------------------------------------------------------------------------- 1 | /** 2 | * book-binder.js 3 | * 4 | * Licensed under the MIT License 5 | * 6 | * Copyright(c) 2019 Google Inc. 7 | */ 8 | 9 | import { Unarchiver, UnarchiveEventType, getUnarchiver } from './bitjs/archive/decompress.js'; 10 | import { BookProgressEvent } from './book-events.js'; 11 | import { config } from './config.js'; 12 | import { Params } from './common/helpers.js'; 13 | 14 | /** @enum */ 15 | export const BookType = { 16 | UNKNOWN: 0, 17 | COMIC: 1, 18 | EPUB: 2, 19 | } 20 | 21 | /** @enum */ 22 | export const UnarchiveState = { 23 | UNARCHIVING_NOT_YET_STARTED: 0, 24 | UNARCHIVING: 1, 25 | UNARCHIVED: 2, 26 | UNARCHIVING_ERROR: 3, 27 | }; 28 | 29 | let EventTarget = Object; 30 | try { EventTarget = window.EventTarget } catch(e) {} 31 | 32 | /** 33 | * The abstract class for a BookBinder. Never instantiate one of these yourself. 34 | * Use createBookBinderAsync() to create an instance of an implementing subclass. 35 | * 36 | * A BookBinder manages unarchiving the relevant files from the incoming bytes and 37 | * emitting useful BookEvents (like progress, page extraction) to subscribers. 38 | */ 39 | export class BookBinder extends EventTarget { 40 | /** @type {number} */ 41 | #bytesLoaded; 42 | 43 | /** @type {number} */ 44 | #totalExpectedSize; 45 | 46 | /** @type {number} */ 47 | #startTime; 48 | 49 | /** @type {UnarchiveState} */ 50 | #unarchiveState = UnarchiveState.UNARCHIVING_NOT_YET_STARTED; 51 | 52 | /** 53 | * A number between 0 and 1 indicating the progress of the Unarchiver. 54 | * @type {number} 55 | */ 56 | #unarchivingPercentage = 0; 57 | 58 | /** @type {Unarchiver} */ 59 | unarchiver; 60 | 61 | /** 62 | * A number between 0 and 1 indicating the progress of the page layout process. 63 | * @type {number} 64 | */ 65 | layoutPercentage = 0; 66 | 67 | /** 68 | * @param {string} fileNameOrUri 69 | * @param {ArrayBuffer} ab The ArrayBuffer to initialize the BookBinder. 70 | * @param {number} totalExpectedSize The total number of bytes expected. 71 | */ 72 | constructor(fileNameOrUri, ab, totalExpectedSize) { 73 | super(); 74 | 75 | // totalExpectedSize can be -1 in the case of an XHR where we do not know the size yet. 76 | if (!totalExpectedSize || totalExpectedSize < -1) { 77 | throw 'Must initialize a BookBinder with a valid totalExpectedSize'; 78 | } 79 | // We do not check (ab instanceof ArrayBuffer) because sometimes the ArrayBuffer is created from 80 | // the JS context's ArrayBuffer prototype (like when it is sent from another Window). 81 | if (!ab || (!ab.byteLength)) { 82 | throw 'Must initialize a BookBinder with an ArrayBuffer'; 83 | } 84 | if (totalExpectedSize > 0 && ab.byteLength > totalExpectedSize) { 85 | throw 'Must initialize a BookBinder with a ab.byteLength <= totalExpectedSize'; 86 | } 87 | 88 | /** @protected {string} */ 89 | this.name_ = fileNameOrUri; 90 | 91 | this.#bytesLoaded = ab.byteLength; 92 | this.#totalExpectedSize = totalExpectedSize > 0 ? totalExpectedSize : this.#bytesLoaded; 93 | 94 | const unarchiverOptions = { 95 | 'pathToBitJS': config.get('PATH_TO_BITJS'), 96 | 'debug': (Params.debug === 'true'), 97 | }; 98 | 99 | this.unarchiver = getUnarchiver(ab, unarchiverOptions); 100 | if (!this.unarchiver) { 101 | throw 'Could not determine the unarchiver to use'; 102 | } 103 | } 104 | 105 | /** 106 | * Appends more bytes to the binder for processing. 107 | * @param {ArrayBuffer} ab 108 | */ 109 | appendBytes(ab) { 110 | if (!ab) { 111 | throw 'Must pass a valid ArrayBuffer to appendBytes()'; 112 | } 113 | if (!this.unarchiver) { 114 | throw 'Called appendBytes() without a valid Unarchiver set'; 115 | } 116 | 117 | // Fetch doesn't give us the full byte size, so we update upon each append. 118 | if (this.#bytesLoaded + ab.byteLength > this.#totalExpectedSize) { 119 | this.#totalExpectedSize = this.#bytesLoaded + ab.byteLength; 120 | } 121 | 122 | this.unarchiver.update(ab); 123 | this.#bytesLoaded += ab.byteLength; 124 | } 125 | 126 | /** 127 | * Override this in an implementing subclass to do things before the Unarchiver starts 128 | * (like subscribe to Unarchiver events). 129 | * @abstract 130 | * @protected 131 | */ 132 | beforeStart_() { 133 | throw 'Cannot call beforeStart_() in abstract BookBinder'; 134 | } 135 | 136 | /** 137 | * @abstract 138 | * @returns {BookType} 139 | */ 140 | getBookType() { 141 | throw 'Cannot call getBookType() in abstract BookBinder'; 142 | } 143 | 144 | /** 145 | * Override this in an implementing subclass. 146 | * @abstract 147 | * @returns {string} The MIME type of the book. 148 | */ 149 | getMIMEType() { 150 | throw 'Cannot call getMIMEType() in abstract BookBinder'; 151 | } 152 | 153 | /** Returns a number between 0 and 1 indicating how much of the bytes have been loaded. */ 154 | getLoadingPercentage() { return this.#bytesLoaded / this.#totalExpectedSize; } 155 | /** Returns a number between 0 and 1 indicating how much of the book has been decompressed. */ 156 | getUnarchivingPercentage() { return this.#unarchivingPercentage; } 157 | /** Returns a number between 0 and 1 indicating how much of the pages have been layed out. */ 158 | getLayoutPercentage() { return this.layoutPercentage; } 159 | 160 | setNewExpectedSize(bytesDownloaded, newExpectedSize) { 161 | this.#bytesLoaded = bytesDownloaded; 162 | this.#totalExpectedSize = newExpectedSize; 163 | } 164 | 165 | /** @protected */ 166 | setUnarchiveComplete() { 167 | this.#unarchiveState = UnarchiveState.UNARCHIVED; 168 | this.#unarchivingPercentage = 1.0; 169 | const diff = ((new Date).getTime() - this.#startTime) / 1000; 170 | console.log(`Book = '${this.name_}'`); 171 | console.log(` using ${this.unarchiver.getScriptFileName()}`); 172 | console.log(` unarchiving done in ${diff}s`); 173 | } 174 | 175 | /** 176 | * Starts the binding process. 177 | * @returns {Promise} A Promise that resolves once the unarchiver implementation has been 178 | * loaded, initialized, connected and the binding process is truly started. 179 | */ 180 | start() { 181 | if (!this.unarchiver) { 182 | throw 'Called start() without a valid Unarchiver'; 183 | } 184 | 185 | this.#startTime = (new Date).getTime(); 186 | this.#unarchiveState = UnarchiveState.UNARCHIVING; 187 | this.unarchiver.addEventListener(UnarchiveEventType.PROGRESS, evt => { 188 | this.#unarchivingPercentage = evt.totalCompressedBytesRead / this.#totalExpectedSize; 189 | // Total # pages is not always equal to the total # of files, so we do not report that here. 190 | this.dispatchEvent(new BookProgressEvent(this)); 191 | }); 192 | 193 | this.unarchiver.addEventListener(UnarchiveEventType.INFO, 194 | evt => console.log(evt.msg)); 195 | 196 | this.beforeStart_(); 197 | this.unarchiver.start(); 198 | 199 | return new Promise((resolve, reject) => { 200 | this.unarchiver.onStart(() => resolve()); 201 | }); 202 | } 203 | 204 | /** 205 | * Must be called from the implementing subclass of BookBinder. Do not call this from 206 | * the client. 207 | * @protected 208 | */ 209 | stop() { 210 | // Stop the Unarchiver (which will kill the worker) and then delete the unarchiver 211 | // which should free up some memory, including the unarchived array buffer. 212 | this.unarchiver.stop(); 213 | this.unarchiver = null; 214 | } 215 | } 216 | 217 | /** 218 | * Creates a book binder based on the type of book. Determines the type of unarchiver to use by 219 | * looking at the first bytes. Guesses the type of book by looking at the file/uri name. 220 | * @param {string} fileNameOrUri The filename or URI. Must end in a file extension that can be 221 | * used to guess what type of book this is. 222 | * @param {ArrayBuffer} ab The initial ArrayBuffer to start the unarchiving process. 223 | * @param {number} totalExpectedSize The total expected size of the archived book in bytes. 224 | * @returns {Promise} A Promise that will resolve with a BookBinder. 225 | */ 226 | export function createBookBinderAsync(fileNameOrUri, ab, totalExpectedSize) { 227 | if (fileNameOrUri.toLowerCase().endsWith('.epub')) { 228 | return import('./epub/epub-book-binder.js').then(module => { 229 | return new module.EPUBBookBinder(fileNameOrUri, ab, totalExpectedSize); 230 | }); 231 | } 232 | return import('./comics/comic-book-binder.js').then(module => { 233 | return new module.ComicBookBinder(fileNameOrUri, ab, totalExpectedSize); 234 | }); 235 | } 236 | -------------------------------------------------------------------------------- /tools/desadulate/desadulate.go: -------------------------------------------------------------------------------- 1 | /** 2 | * desadulate.go 3 | * 4 | * Licensed under the MIT License 5 | * 6 | * Copyright(c) 2021 Google Inc. 7 | */ 8 | 9 | // This pipeline makes a "better" version of a comic book or epub file. 10 | package main 11 | 12 | import ( 13 | "flag" 14 | "fmt" 15 | "io" 16 | "io/ioutil" 17 | "log" 18 | "os" 19 | "os/exec" 20 | "path/filepath" 21 | "sort" 22 | "strings" 23 | 24 | "github.com/codedread/kthoom/tools/modules/archives" 25 | "github.com/codedread/kthoom/tools/modules/books/comic" 26 | "github.com/codedread/kthoom/tools/modules/books/epub" 27 | "github.com/codedread/kthoom/tools/modules/books/metadata" 28 | "github.com/codedread/kthoom/tools/modules/images" 29 | ) 30 | 31 | var inpath string 32 | var outpath string 33 | var infile string 34 | 35 | var caseSensitiveSort bool 36 | var slobMode bool 37 | var verboseMode bool 38 | var webpMode bool 39 | 40 | var outfile string 41 | 42 | var allowedExtensions = []string{ 43 | ".cbr", 44 | ".cbz", 45 | ".epub", 46 | } 47 | 48 | // TODO: Fail if inpath === outpath? 49 | 50 | func checkExecutables() { 51 | var err error 52 | commands := []string{"unrar", "unzip", "zip"} 53 | for _, cmd := range commands { 54 | _, err = exec.LookPath(cmd) 55 | if err != nil { 56 | log.Fatalf("Error: Cannot find executable '%s'\n", cmd) 57 | } 58 | } 59 | } 60 | 61 | func parseCommandLineFlags() { 62 | flag.StringVar(&inpath, "i", "", "Base path of the input directory (required)") 63 | flag.StringVar(&infile, "f", "", "Path of the input file relative to the input directory (required)") 64 | flag.StringVar(&outpath, "o", "", "Base path to the output directory (required)") 65 | flag.BoolVar(&caseSensitiveSort, "cs", false, "Case-sensitive sorting of filenames (optional)") 66 | flag.BoolVar(&slobMode, "slob", false, "Do not clean up temporary directory (optional)") 67 | flag.BoolVar(&verboseMode, "v", false, "Verbose mode (optional)") 68 | flag.BoolVar(&webpMode, "webp", false, "Convert images to webp format (optional)") 69 | flag.Parse() 70 | 71 | if inpath == "" { 72 | flag.PrintDefaults() 73 | log.Fatalf("Error: Invalid -i flag usage") 74 | } 75 | 76 | if outpath == "" { 77 | flag.PrintDefaults() 78 | log.Fatalf("Error: Invalid -o flag usage") 79 | } 80 | 81 | if infile == "" { 82 | flag.PrintDefaults() 83 | log.Fatalf("Error: Invalid -f flag usage") 84 | } 85 | } 86 | 87 | func resolveFilenames() { 88 | var err error 89 | 90 | inpath, err = filepath.Abs(inpath) 91 | if err != nil { 92 | log.Fatalf("Error: Cannot find absolute path of '%s'\n", inpath) 93 | } 94 | 95 | outpath, err = filepath.Abs(outpath) 96 | if err != nil { 97 | log.Fatalf("Error: Cannot find absolute path of '%s'\n", outpath) 98 | } 99 | 100 | outfile = filepath.Join(outpath, infile) 101 | infile = filepath.Join(inpath, infile) 102 | } 103 | 104 | func validateInputFile() error { 105 | fileStatInfo, err := os.Stat(infile) 106 | if os.IsNotExist(err) { 107 | return fmt.Errorf("'%s' does not exist\n", infile) 108 | } 109 | if !fileStatInfo.Mode().IsRegular() { 110 | return fmt.Errorf("'%s' is not a regular file\n", infile) 111 | } 112 | 113 | ext := filepath.Ext(infile) 114 | extensionAllowed := false 115 | for _, allowedExt := range allowedExtensions { 116 | if allowedExt == ext { 117 | extensionAllowed = true 118 | break 119 | } 120 | } 121 | if !extensionAllowed { 122 | return fmt.Errorf("'%s' is an unknown extension\n", ext) 123 | } 124 | 125 | return nil 126 | } 127 | 128 | func validateOutputPath() error { 129 | fileStatInfo, err := os.Stat(outpath) 130 | if os.IsNotExist(err) { 131 | return fmt.Errorf("'%s' does not exist\n", outpath) 132 | } 133 | if !fileStatInfo.Mode().IsDir() { 134 | return fmt.Errorf("'%s' is not a directory\n", infile) 135 | } 136 | 137 | return nil 138 | } 139 | 140 | func prepareToWriteOutputFile() { 141 | if _, err := os.Stat(outfile); err == nil { 142 | err = os.Remove(outfile) 143 | if err != nil { 144 | log.Fatalf("Could not delete %s: %v\n", outfile, err) 145 | } else if verboseMode { 146 | fmt.Printf("File already existed, deleted %s before re-creating\n", outfile) 147 | } 148 | } 149 | 150 | if err := os.MkdirAll(filepath.Dir(outfile), os.ModePerm); err != nil { 151 | log.Fatalf("Failed to create output directory: %v\n", err) 152 | } else if verboseMode { 153 | fmt.Printf("Prepared output final directories\n") 154 | } 155 | } 156 | 157 | func getOutWriter() io.Writer { 158 | if verboseMode { 159 | return os.Stdout 160 | } 161 | return ioutil.Discard 162 | } 163 | 164 | func main() { 165 | parseCommandLineFlags() 166 | resolveFilenames() 167 | checkExecutables() 168 | 169 | if verboseMode { 170 | fmt.Printf("betterize: inpath is '%s'\n", inpath) 171 | fmt.Printf("betterize: input file is '%s'\n", infile) 172 | fmt.Printf("betterize: outpath is '%s'\n", outpath) 173 | fmt.Printf("betterize: output file is '%s'\n", outfile) 174 | } 175 | 176 | if err := validateInputFile(); err != nil { 177 | log.Fatalf(err.Error()) 178 | } else if verboseMode { 179 | fmt.Println("betterize: input file is ok!") 180 | } 181 | 182 | if err := validateOutputPath(); err != nil { 183 | log.Fatalf(err.Error()) 184 | } else if verboseMode { 185 | fmt.Println("betterize: outpath is ok!") 186 | } 187 | 188 | relPath := filepath.Dir(outfile) 189 | if verboseMode { 190 | fmt.Printf("Output output path is %s\n", relPath) 191 | } 192 | 193 | theArchive, archiveErr := archives.ExtractArchive(infile, getOutWriter(), os.Stderr) 194 | if !slobMode { 195 | defer archives.CleanupArchive(theArchive) 196 | } 197 | 198 | if archiveErr != nil { 199 | log.Fatalf(archiveErr.Error()) 200 | } else if verboseMode { 201 | fmt.Printf("betterize: Found an archive of type %s\n", theArchive.ArchiveType) 202 | fmt.Printf("betterize: temp directory is '%s'\n", theArchive.TmpDir) 203 | } 204 | 205 | if theArchive.ArchiveType == archives.ComicBook { 206 | outfile = strings.TrimSuffix(outfile, filepath.Ext(outfile)) + ".cbz" 207 | outfile = strings.Replace(outfile, " ", "_", -1) 208 | outfile = strings.Replace(outfile, "(", "", -1) 209 | outfile = strings.Replace(outfile, ")", "", -1) 210 | outfile = strings.Replace(outfile, "#", "", -1) 211 | if verboseMode { 212 | fmt.Printf("betterize: outfile rewritten to %s\n", outfile) 213 | } 214 | 215 | theBook, extractErr := comic.ExtractBookFromArchive(theArchive, getOutWriter(), os.Stderr) 216 | if extractErr != nil { 217 | log.Fatalf(extractErr.Error()) 218 | } 219 | 220 | // TODO: Move this optimization stuff into books/comic. 221 | // TODO: This sorts alphabetically, but some books have bad filenames. We need to handle special cases here 222 | // like foo8.jpg, foo9.jpg, foo10.jpg. 223 | // Sort all page files. 224 | if caseSensitiveSort { 225 | sort.Strings(theBook.PageFiles) 226 | } else { 227 | // Default is case-insensitive sort. 228 | sort.Slice(theBook.PageFiles, func(i, j int) bool { 229 | return strings.ToLower(theBook.PageFiles[i]) < strings.ToLower(theBook.PageFiles[j]) 230 | }) 231 | } 232 | 233 | if webpMode { 234 | for i, pageFilename := range theBook.PageFiles { 235 | newPageFilename, convertErr := images.ConvertFileToWebp(pageFilename, getOutWriter(), os.Stderr) 236 | if convertErr != nil { 237 | fmt.Fprintf(os.Stderr, "Webp conversion failed with %s/%s, skipping\n", theBook.ArchiveFilename, pageFilename) 238 | continue 239 | } 240 | 241 | theBook.PageFiles[i] = newPageFilename 242 | if verboseMode { 243 | fmt.Printf("New page filename is %s\n", newPageFilename) 244 | } 245 | } 246 | } 247 | 248 | // Get or create the comic book metadata and optimize it for streaming. 249 | if theBook.Metadata == nil { 250 | theBook.Metadata = &comic.ComicInfo{} 251 | } 252 | if theBook.Metadata.ArchiveFileInfo == nil { 253 | theBook.Metadata.ArchiveFileInfo = &metadata.ArchiveFileInfo{} 254 | } 255 | theBook.Metadata.ArchiveFileInfo.OptimizedForStreaming = "true" 256 | 257 | prepareToWriteOutputFile() 258 | comic.CreateArchiveFromBook(theBook, outfile, getOutWriter(), os.Stderr) 259 | 260 | fmt.Printf("betterize: created %s\n", outfile) 261 | } else if theArchive.ArchiveType == archives.EPub { 262 | theBook, extractErr := epub.ExtractBookFromArchive(theArchive, getOutWriter(), os.Stderr) 263 | if extractErr != nil { 264 | log.Fatalf(extractErr.Error()) 265 | } 266 | 267 | if theBook == nil { 268 | log.Fatalf("EPub Book could not be created!\n") 269 | } 270 | 271 | if orderErr := epub.CreateOrderedBook(theBook, getOutWriter(), os.Stderr); orderErr != nil { 272 | log.Fatalf("EPub Book encountered an error while ordering: %v\n", orderErr) 273 | } 274 | 275 | // Update metadata. 276 | if theBook.Package.ArchiveFileInfo == nil { 277 | theBook.Package.ArchiveFileInfo = &metadata.ArchiveFileInfo{} 278 | } 279 | theBook.Package.ArchiveFileInfo.OptimizedForStreaming = "true" 280 | 281 | // Write it out. 282 | epub.CreateArchiveFromBook(theBook, outfile, getOutWriter(), os.Stderr) 283 | } 284 | 285 | if verboseMode { 286 | fmt.Printf("betterize: done.\n") 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /tests/pages/one-page-setter.test.js: -------------------------------------------------------------------------------- 1 | import { FitMode } from '../../code/book-viewer-types.js'; 2 | import { OnePageSetter } from '../../code/pages/one-page-setter.js'; 3 | 4 | import 'mocha'; 5 | import { expect } from 'chai'; 6 | 7 | /** @typedef {import('../../code/book-viewer-types.js').Box} Box */ 8 | /** @typedef {import('../../code/book-viewer-types.js').PageLayoutParams} PageLayoutParams */ 9 | /** @typedef {import('../../code/book-viewer-types.js').PageSetting} PageSetting */ 10 | 11 | describe('OnePageSetter', () => { 12 | /** @type {PageLayoutParams} */ 13 | let layoutParams; 14 | 15 | /** @type {OnePageSetter} */ 16 | let setter; 17 | 18 | beforeEach(() => { 19 | layoutParams = {}; 20 | setter = new OnePageSetter(); 21 | }); 22 | 23 | describe('FitMode.Width', () => { 24 | beforeEach(() => { 25 | layoutParams.fitMode = FitMode.Width; 26 | }); 27 | 28 | describe('no rotation', () => { 29 | beforeEach(() => { 30 | layoutParams.rotateTimes = 0; 31 | }); 32 | 33 | it(`sizes page properly when par < bvar`, () => { 34 | const PAGE_ASPECT_RATIO = 0.5; 35 | const BV_WIDTH = 400; 36 | const BV_HEIGHT = 400; 37 | layoutParams.pageAspectRatio = PAGE_ASPECT_RATIO; 38 | layoutParams.bv = { left: 0, top: 0, width: BV_WIDTH, height: BV_HEIGHT }; 39 | 40 | /** @type {PageSetting} */ 41 | const pageSetting = setter.updateLayout(layoutParams); 42 | expect(pageSetting.boxes).to.be.an('array'); 43 | expect(pageSetting.boxes).to.have.lengthOf(1); 44 | 45 | /** @type {Box} */ 46 | const box1 = pageSetting.boxes[0]; 47 | expect(box1.width).equals(BV_WIDTH); 48 | expect(box1.height).equals(BV_WIDTH/PAGE_ASPECT_RATIO); 49 | expect(box1.left).equals(0); 50 | expect(box1.top).equals(0); 51 | }); 52 | 53 | it(`centers page vertically when par > bvar`, () => { 54 | const PAGE_ASPECT_RATIO = 0.5; 55 | const BV_WIDTH = 400; 56 | const BV_HEIGHT = 1200; 57 | layoutParams.pageAspectRatio = PAGE_ASPECT_RATIO; 58 | layoutParams.bv = { left: 0, top: 0, width: BV_WIDTH, height: BV_HEIGHT }; 59 | 60 | /** @type {PageSetting} */ 61 | const pageSetting = setter.updateLayout(layoutParams); 62 | expect(pageSetting.boxes).to.be.an('array'); 63 | expect(pageSetting.boxes).to.have.lengthOf(1); 64 | 65 | /** @type {Box} */ 66 | const box1 = pageSetting.boxes[0]; 67 | expect(box1.width).equals(BV_WIDTH); 68 | expect(box1.height).equals(BV_WIDTH / PAGE_ASPECT_RATIO); 69 | expect(box1.left).equals(0); 70 | expect(box1.top).equals((BV_HEIGHT - BV_WIDTH / PAGE_ASPECT_RATIO) / 2); 71 | }); 72 | }); 73 | 74 | describe('rotated cw', () => { 75 | beforeEach(() => { 76 | layoutParams.rotateTimes = 1; 77 | }); 78 | 79 | it(`sizes page properly when par <= 1/bvar`, () => { 80 | const PAGE_ASPECT_RATIO = 0.5; 81 | const BV_WIDTH = 200; 82 | const BV_HEIGHT = 400; 83 | layoutParams.pageAspectRatio = PAGE_ASPECT_RATIO; 84 | layoutParams.bv = { left: 0, top: 0, width: BV_WIDTH, height: BV_HEIGHT }; 85 | 86 | /** @type {PageSetting} */ 87 | const pageSetting = setter.updateLayout(layoutParams); 88 | 89 | /** @type {Box} */ 90 | const box1 = pageSetting.boxes[0]; 91 | expect(box1.width).equals(BV_HEIGHT); 92 | expect(box1.height).equals(BV_HEIGHT / PAGE_ASPECT_RATIO); 93 | 94 | const center = { x: BV_WIDTH / 2, y: BV_HEIGHT / 2 }; 95 | expect(box1.left).equals(center.x - BV_HEIGHT / 2); 96 | // Since it's been rotated clockwise, we expect the top to extend above. 97 | expect(box1.top).equals(center.y - BV_WIDTH / 2 + BV_WIDTH - box1.height); 98 | 99 | // Since the page's aspect ratio is 1/2, but it's been rotated (aspect ratio = 2) 100 | // We expect the bookViewer's width to have been expanded so it can scroll. 101 | expect(pageSetting.bv.width).equals(BV_HEIGHT / PAGE_ASPECT_RATIO); 102 | expect(pageSetting.bv.height).equals(BV_HEIGHT); 103 | }); 104 | 105 | it(`centers page horizontally when par > 1/bvar`, () => { 106 | const PAGE_ASPECT_RATIO = 0.5; 107 | const BV_WIDTH = 1200; 108 | const BV_HEIGHT = 400; // bvar = 3 109 | layoutParams.pageAspectRatio = PAGE_ASPECT_RATIO; 110 | layoutParams.bv = { left: 0, top: 0, width: BV_WIDTH, height: BV_HEIGHT }; 111 | 112 | /** @type {PageSetting} */ 113 | const pageSetting = setter.updateLayout(layoutParams); 114 | 115 | /** @type {Box} */ 116 | const box1 = pageSetting.boxes[0]; 117 | expect(box1.width).equals(BV_HEIGHT); 118 | expect(box1.height).equals(BV_HEIGHT / PAGE_ASPECT_RATIO); 119 | 120 | const center = { x: BV_WIDTH / 2, y: BV_HEIGHT / 2 }; 121 | expect(box1.left).equals(center.x - BV_HEIGHT / 2); 122 | expect(box1.top).equals(center.y - box1.height / 2); 123 | }); 124 | }); 125 | }); 126 | 127 | describe('FitMode.Height', () => { 128 | beforeEach(() => { 129 | layoutParams.fitMode = FitMode.Height; 130 | }); 131 | 132 | describe('no rotation', () => { 133 | beforeEach(() => { 134 | layoutParams.rotateTimes = 0; 135 | }); 136 | 137 | it(`sizes page properly when par > bvar`, () => { 138 | const PAGE_ASPECT_RATIO = 2; 139 | const BV_WIDTH = 200; 140 | const BV_HEIGHT = 400; 141 | layoutParams.pageAspectRatio = PAGE_ASPECT_RATIO; 142 | layoutParams.bv = { left: 0, top: 0, width: BV_WIDTH, height: BV_HEIGHT }; 143 | 144 | /** @type {PageSetting} */ 145 | const pageSetting = setter.updateLayout(layoutParams); 146 | expect(pageSetting.boxes).to.be.an('array'); 147 | expect(pageSetting.boxes).to.have.lengthOf(1); 148 | 149 | /** @type {Box} */ 150 | const box1 = pageSetting.boxes[0]; 151 | expect(box1.top).equals(0); 152 | expect(box1.height).equals(BV_HEIGHT); 153 | expect(box1.left).equals(0); 154 | expect(box1.width).equals(BV_HEIGHT * PAGE_ASPECT_RATIO); 155 | 156 | // Width of the book viewer should have been changed. 157 | expect(pageSetting.bv.width).equals(BV_HEIGHT * PAGE_ASPECT_RATIO); 158 | }); 159 | 160 | it(`centers horizontally when par < bvar`, () => { 161 | const PAGE_ASPECT_RATIO = 0.5; 162 | const BV_WIDTH = 400; 163 | const BV_HEIGHT = 400; 164 | layoutParams.pageAspectRatio = PAGE_ASPECT_RATIO; 165 | layoutParams.bv = { left: 0, top: 0, width: BV_WIDTH, height: BV_HEIGHT }; 166 | 167 | /** @type {PageSetting} */ 168 | const pageSetting = setter.updateLayout(layoutParams); 169 | 170 | /** @type {Box} */ 171 | const box1 = pageSetting.boxes[0]; 172 | expect(box1.top).equals(0); 173 | expect(box1.height).equals(BV_HEIGHT); 174 | expect(box1.width).equals(BV_HEIGHT * PAGE_ASPECT_RATIO); 175 | expect(box1.left).equals((BV_WIDTH - box1.width)/2); 176 | }); 177 | }); 178 | 179 | describe('rotated cw', () => { 180 | beforeEach(() => { 181 | layoutParams.rotateTimes = 1; 182 | }); 183 | 184 | it(`sizes page properly when par >= 1/bvar`, () => { 185 | const PAGE_ASPECT_RATIO = 2; 186 | const BV_WIDTH = 400; 187 | const BV_HEIGHT = 200; 188 | layoutParams.pageAspectRatio = PAGE_ASPECT_RATIO; 189 | layoutParams.bv = { left: 0, top: 0, width: BV_WIDTH, height: BV_HEIGHT }; 190 | 191 | /** @type {PageSetting} */ 192 | const pageSetting = setter.updateLayout(layoutParams); 193 | expect(pageSetting.boxes).to.be.an('array'); 194 | expect(pageSetting.boxes).to.have.lengthOf(1); 195 | 196 | /** @type {Box} */ 197 | const box1 = pageSetting.boxes[0]; 198 | expect(box1.height).equals(BV_WIDTH); 199 | expect(box1.width).equals(BV_WIDTH * PAGE_ASPECT_RATIO); 200 | 201 | const center = { x: BV_WIDTH / 2, y: BV_HEIGHT / 2 }; 202 | expect(box1.top).equals(center.y - BV_WIDTH / 2); 203 | // Since it's been rotated clockwise, we expect the top to extend above. 204 | expect(box1.left).equals(center.x - BV_HEIGHT / 2); // + BV_HEIGHT - box1.width); 205 | 206 | // Width of the book viewer should have been changed. 207 | expect(pageSetting.bv.height).equals(box1.width); 208 | }); 209 | 210 | it(`centers page horizontally when par < 1/bvar`, () => { 211 | const PAGE_ASPECT_RATIO = 0.5; 212 | const BV_WIDTH = 400; 213 | const BV_HEIGHT = 800; // bvar = 0.5 214 | layoutParams.pageAspectRatio = PAGE_ASPECT_RATIO; 215 | layoutParams.bv = { left: 0, top: 0, width: BV_WIDTH, height: BV_HEIGHT }; 216 | 217 | /** @type {PageSetting} */ 218 | const pageSetting = setter.updateLayout(layoutParams); 219 | 220 | /** @type {Box} */ 221 | const box1 = pageSetting.boxes[0]; 222 | expect(box1.height).equals(BV_WIDTH); 223 | expect(box1.width).equals(BV_WIDTH * PAGE_ASPECT_RATIO); 224 | 225 | const center = { x: BV_WIDTH / 2, y: BV_HEIGHT / 2 }; 226 | expect(box1.top).equals(center.y - box1.height / 2); 227 | expect(box1.left).equals(center.x - box1.width / 2); 228 | }); 229 | }); 230 | 231 | // TODO: Add tests for rotate 180deg and rotate 270deg. 232 | }); 233 | }); 234 | -------------------------------------------------------------------------------- /code/bitjs/archive/compress.js: -------------------------------------------------------------------------------- 1 | /** 2 | * compress.js 3 | * 4 | * Provides base functionality for compressing. 5 | * 6 | * Licensed under the MIT License 7 | * 8 | * Copyright(c) 2023 Google Inc. 9 | */ 10 | 11 | import { ZipCompressionMethod, getConnectedPort } from './common.js'; 12 | 13 | // TODO(2.0): Remove this comment. 14 | // NOTE: THIS IS A WORK-IN-PROGRESS! THE API IS NOT FROZEN! USE AT YOUR OWN RISK! 15 | 16 | /** 17 | * @typedef FileInfo An object that is sent to the implementation to represent a file to zip. 18 | * @property {string} fileName The name of the file. TODO: Includes the path? 19 | * @property {number} lastModTime The number of ms since the Unix epoch (1970-01-01 at midnight). 20 | * @property {Uint8Array} fileData The bytes of the file. 21 | */ 22 | 23 | /** The number of milliseconds to periodically send any pending files to the Worker. */ 24 | const FLUSH_TIMER_MS = 50; 25 | 26 | /** 27 | * Data elements are packed into bytes in order of increasing bit number within the byte, 28 | * i.e., starting with the least-significant bit of the byte. 29 | * Data elements other than Huffman codes are packed starting with the least-significant bit of the 30 | * data element. 31 | * Huffman codes are packed starting with the most-significant bit of the code. 32 | */ 33 | 34 | /** 35 | * @typedef CompressorOptions 36 | * @property {ZipCompressionMethod} zipCompressionMethod 37 | */ 38 | 39 | /** 40 | * @readonly 41 | * @enum {string} 42 | */ 43 | export const CompressStatus = { 44 | NOT_STARTED: 'not_started', 45 | READY: 'ready', 46 | WORKING: 'working', 47 | COMPLETE: 'complete', 48 | ERROR: 'error', 49 | }; 50 | 51 | // TODO: Extend EventTarget and introduce subscribe methods (onProgress, onInsert, onFinish, etc). 52 | 53 | /** 54 | * A thing that zips files. 55 | * NOTE: THIS IS A WORK-IN-PROGRESS! THE API IS NOT FROZEN! USE AT YOUR OWN RISK! 56 | * TODO(2.0): Add semantic onXXX methods for an event-driven API. 57 | */ 58 | export class Zipper { 59 | /** 60 | * @type {Uint8Array} 61 | * @private 62 | */ 63 | byteArray = new Uint8Array(0); 64 | 65 | /** 66 | * The overall state of the Zipper. 67 | * @type {CompressStatus} 68 | * @private 69 | */ 70 | compressStatus_ = CompressStatus.NOT_STARTED; 71 | // Naming of this property preserved for compatibility with 1.2.4-. 72 | get compressState() { return this.compressStatus_; } 73 | 74 | /** 75 | * The client-side port that sends messages to, and receives messages from the 76 | * decompressor implementation. 77 | * @type {MessagePort} 78 | * @private 79 | */ 80 | port_; 81 | 82 | /** 83 | * A function to call to disconnect the implementation from the host. 84 | * @type {Function} 85 | * @private 86 | */ 87 | disconnectFn_; 88 | 89 | /** 90 | * A timer that periodically flushes pending files to the Worker. Set upon start() and stopped 91 | * upon the last file being compressed by the Worker. 92 | * @type {Number} 93 | * @private 94 | */ 95 | flushTimer_ = 0; 96 | 97 | /** 98 | * Whether the last files have been added by the client. 99 | * @type {boolean} 100 | * @private 101 | */ 102 | lastFilesReceived_ = false; 103 | 104 | /** 105 | * The pending files to be sent to the Worker. 106 | * @type {FileInfo[]} 107 | * @private 108 | */ 109 | pendingFilesToSend_ = []; 110 | 111 | /** 112 | * @param {CompressorOptions} options 113 | */ 114 | constructor(options) { 115 | /** 116 | * @type {CompressorOptions} 117 | * @private 118 | */ 119 | this.zipOptions = options; 120 | this.zipCompressionMethod = options.zipCompressionMethod || ZipCompressionMethod.STORE; 121 | if (!Object.values(ZipCompressionMethod).includes(this.zipCompressionMethod)) { 122 | throw `Compression method ${this.zipCompressionMethod} not supported`; 123 | } 124 | 125 | if (this.zipCompressionMethod === ZipCompressionMethod.DEFLATE) { 126 | // As per https://developer.mozilla.org/en-US/docs/Web/API/CompressionStream, NodeJS only 127 | // supports deflate-raw from 21.2.0+ (Nov 2023). https://nodejs.org/en/blog/release/v21.2.0. 128 | try { 129 | new CompressionStream('deflate-raw'); 130 | } catch (err) { 131 | throw `CompressionStream with deflate-raw not supported by JS runtime: ${err}`; 132 | } 133 | } 134 | } 135 | 136 | /** 137 | * Must only be called on a Zipper that has been started. See start(). 138 | * @param {FileInfo[]} files 139 | * @param {boolean} isLastFile 140 | */ 141 | appendFiles(files, isLastFile = false) { 142 | if (this.compressStatus_ === CompressStatus.NOT_STARTED) { 143 | throw `appendFiles() called, but Zipper not started.`; 144 | } 145 | if (this.lastFilesReceived_) throw `appendFiles() called, but last file already received.`; 146 | 147 | this.lastFilesReceived_ = isLastFile; 148 | this.pendingFilesToSend_.push(...files); 149 | } 150 | 151 | /** 152 | * Send in a set of files to be compressed. Set isLastFile to true if no more files are to be 153 | * added in the future. The return Promise will not resolve until isLastFile is set to true either 154 | * in this method or in an appendFiles() call. 155 | * @param {FileInfo[]} files 156 | * @param {boolean} isLastFile 157 | * @returns {Promise} A Promise that will resolve once the final file has been sent. 158 | * The Promise resolves to an array of bytes of the entire zipped archive. 159 | */ 160 | async start(files = [], isLastFile = false) { 161 | if (this.compressStatus_ !== CompressStatus.NOT_STARTED) { 162 | throw `start() called, but Zipper already started.`; 163 | } 164 | 165 | // We optimize for the case where isLastFile=true in a start() call by posting to the Worker 166 | // immediately upon async resolving below. Otherwise, we push these files into the pending set 167 | // and rely on the flush timer to send them into the Worker. 168 | if (!isLastFile) { 169 | this.pendingFilesToSend_.push(...files); 170 | this.flushTimer_ = setInterval(() => this.flushAnyPendingFiles_(), FLUSH_TIMER_MS); 171 | } 172 | this.compressStatus_ = CompressStatus.READY; 173 | this.lastFilesReceived_ = isLastFile; 174 | 175 | // After this point, the function goes async, so appendFiles() may run before anything else in 176 | // this function. 177 | const impl = await getConnectedPort('./zip.js'); 178 | this.port_ = impl.hostPort; 179 | this.disconnectFn_ = impl.disconnectFn; 180 | return new Promise((resolve, reject) => { 181 | this.port_.onerror = (evt) => { 182 | console.log('Impl error: message = ' + evt.message); 183 | reject(evt.message); 184 | }; 185 | 186 | this.port_.onmessage = (evt) => { 187 | if (typeof evt.data == 'string') { 188 | // Just log any strings the implementation pumps our way. 189 | console.log(evt.data); 190 | } else { 191 | switch (evt.data.type) { 192 | // Message sent back upon the first message the Worker receives, which may or may not 193 | // have sent any files for compression, e.g. start([]). 194 | case 'start': 195 | this.compressStatus_ = CompressStatus.WORKING; 196 | break; 197 | // Message sent back when the last file has been compressed by the Worker. 198 | case 'finish': 199 | if (this.flushTimer_) { 200 | clearInterval(this.flushTimer_); 201 | this.flushTimer_ = 0; 202 | } 203 | this.compressStatus_ = CompressStatus.COMPLETE; 204 | this.port_.close(); 205 | this.disconnectFn_(); 206 | this.port_ = null; 207 | this.disconnectFn_ = null; 208 | resolve(this.byteArray); 209 | break; 210 | // Message sent back when the Worker has written some bytes to the zip file. 211 | case 'compress': 212 | this.addBytes_(evt.data.bytes); 213 | break; 214 | } 215 | } 216 | }; 217 | 218 | // See note above about optimizing for the start(files, true) case. 219 | if (isLastFile) { 220 | this.port_.postMessage({ files, isLastFile, compressionMethod: this.zipCompressionMethod }); 221 | } 222 | }); 223 | } 224 | 225 | /** 226 | * Updates the internal byte array with new bytes (by allocating a new array and copying). 227 | * @param {Uint8Array} newBytes 228 | * @private 229 | */ 230 | addBytes_(newBytes) { 231 | const oldArray = this.byteArray; 232 | this.byteArray = new Uint8Array(oldArray.byteLength + newBytes.byteLength); 233 | this.byteArray.set(oldArray); 234 | this.byteArray.set(newBytes, oldArray.byteLength); 235 | } 236 | 237 | /** 238 | * Called internally by the async machinery to send any pending files to the Worker. This method 239 | * sends at most one message to the Worker. 240 | * @private 241 | */ 242 | flushAnyPendingFiles_() { 243 | if (this.compressStatus_ === CompressStatus.NOT_STARTED) { 244 | throw `flushAppendFiles_() called but Zipper not started.`; 245 | } 246 | // If the port is not initialized or we have no pending files, just return immediately and 247 | // try again on the next flush. 248 | if (!this.port_ || this.pendingFilesToSend_.length === 0) return; 249 | 250 | // Send all files to the worker. If we have received the last file, then let the Worker know. 251 | this.port_.postMessage({ 252 | files: this.pendingFilesToSend_, 253 | isLastFile: this.lastFilesReceived_, 254 | compressionMethod: this.zipCompressionMethod, 255 | }); 256 | // Release the memory from the browser's main thread. 257 | this.pendingFilesToSend_ = []; 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /code/kthoom-google.js: -------------------------------------------------------------------------------- 1 | /** 2 | * kthoom-google.js 3 | * 4 | * Licensed under the MIT License 5 | * 6 | * Copyright(c) 2018 Google Inc. 7 | */ 8 | 9 | /** 10 | * Code for handling file access through Google Drive. 11 | * Ideally, we don't want any Google code to load unless the user clicks 12 | * the Open menu item. 13 | */ 14 | 15 | if (window.kthoom == undefined) { 16 | window.kthoom = {}; 17 | } 18 | 19 | let openMenu; 20 | 21 | function defineGoogleHooks() { 22 | const SCOPE = [ 23 | 'https://www.googleapis.com/auth/userinfo.email', 24 | 'https://www.googleapis.com/auth/userinfo.profile', 25 | 'https://www.googleapis.com/auth/drive.readonly', 26 | ].join(' '); 27 | 28 | // TODO: Turn this script into a module and remove most things from window.kthoom.google. 29 | window.kthoom.google = { 30 | isBooted: false, 31 | isSignedIn: false, 32 | isAuthorized: false, 33 | isReadyToCallAPIs: false, 34 | oathToken: undefined, 35 | authInstance: undefined, 36 | 37 | async boot() { 38 | if (typeof gapi === 'undefined') { 39 | // Load the Google API script. 40 | await new Promise((resolve, reject) => { 41 | // If we cannot load the Google API script, then die. 42 | const gScript = document.createElement('script'); 43 | gScript.setAttribute('async', 'async'); 44 | gScript.setAttribute('defer', 'defer'); 45 | gScript.onerror = err => reject(err); 46 | gScript.addEventListener('load', () => resolve()); 47 | gScript.setAttribute('src', 'https://apis.google.com/js/api.js'); 48 | document.body.appendChild(gScript); 49 | }); 50 | 51 | // Load initial API client and auth2 modules. 52 | await new Promise((resolve, reject) => { 53 | if (typeof gapi === 'undefined') { 54 | reject('gapi was not defined'); 55 | } 56 | gapi.load('client:auth2', { 57 | callback: () => { 58 | gapi.client.init({ 59 | 'apiKey': kthoom.google.apiKey, 60 | 'clientId': kthoom.google.clientId, 61 | 'scope': SCOPE, 62 | // 'discoveryDocs': [ 63 | // 'https://www.googleapis.com/discovery/v1/apis/drive/v3/rest', 64 | // ], 65 | }).then(() => { 66 | kthoom.google.isBooted = true; 67 | 68 | // See if we are already signed in and authorized. 69 | const authInstance = gapi.auth2.getAuthInstance(); 70 | const currentUser = authInstance.currentUser.get(); 71 | authInstance.isSignedIn.listen(signedIn => { 72 | kthoom.google.isSignedIn = signedIn; 73 | }); 74 | kthoom.google.authInstance = authInstance; 75 | kthoom.google.isSignedIn = authInstance.isSignedIn.get(); 76 | 77 | const hasScopes = currentUser.hasGrantedScopes(SCOPE); 78 | if (hasScopes) { 79 | const result = gapi.client.getToken(); 80 | // TODO: It is possible to revoke kthoom's access, but this still returns 81 | // an access token. 82 | if (result && !result.error && result.access_token) { 83 | kthoom.google.isAuthorized = true; 84 | kthoom.google.oathToken = result.access_token; 85 | gapi.client.setApiKey(kthoom.google.oauthToken); 86 | } 87 | } 88 | resolve(); 89 | }, err => { 90 | debugger; 91 | reject(err); 92 | }); 93 | }, 94 | onerror: err => reject(err), 95 | }); 96 | }); 97 | } 98 | }, 99 | 100 | async authorize() { 101 | if (!kthoom.google.isBooted) { 102 | await kthoom.google.boot(); 103 | } 104 | 105 | // If signed in, but never were granted scopes or an OAuth token, sign out. 106 | if (kthoom.google.isSignedIn && !kthoom.google.isAuthorized) { 107 | await kthoom.authInstance.signOut(); 108 | // TODO: Should we call authInstance.disconnect() ? 109 | kthoom.google.isSignedIn = false; 110 | } 111 | 112 | // If not signed in, then do it. 113 | if (!kthoom.google.isSignedIn) { 114 | await kthoom.google.authInstance.signIn(); 115 | kthoom.google.isSignedIn = true; 116 | } 117 | 118 | const result = gapi.client.getToken(); 119 | if (result && !result.error && result.access_token) { 120 | kthoom.google.isAuthorized = true; 121 | kthoom.google.oathToken = result.access_token; 122 | gapi.client.setApiKey(kthoom.google.oauthToken); 123 | } else { 124 | throw `Not authorized: ${result.error}`; 125 | } 126 | }, 127 | 128 | async loadAPILibs() { 129 | if (!kthoom.google.isBooted) { 130 | await kthoom.google.boot(); 131 | } 132 | 133 | if (!kthoom.google.isAuthorized) { 134 | await kthoom.google.authorize(); 135 | } 136 | 137 | return new Promise((resolve, reject) => { 138 | // Load the Drive and Picker APIs. 139 | gapi.client.load('drive', 'v2', () => { 140 | gapi.load('picker', { 141 | callback: () => { 142 | kthoom.google.isReadyToCallAPIs = true; 143 | resolve(); 144 | }, 145 | onerror: err => reject(err), 146 | timeout: 5000, 147 | ontimeout: () => { 148 | reject('gapi.load(picker) timed out'); 149 | }, 150 | }); 151 | }, err => { 152 | reject(err); 153 | }) 154 | }); 155 | }, 156 | 157 | async doDrive() { 158 | // TODO: Show "Please wait" or a spinner while things get ready. 159 | if (!kthoom.google.isBooted) { 160 | await kthoom.google.boot(); 161 | } 162 | 163 | if (!kthoom.google.isAuthorized) { 164 | await kthoom.google.authorize(); 165 | } 166 | 167 | if (!kthoom.google.isReadyToCallAPIs) { 168 | await kthoom.google.loadAPILibs(); 169 | } 170 | 171 | const docsView = new google.picker.DocsView(); 172 | docsView.setMode(google.picker.DocsViewMode.LIST); 173 | docsView.setQuery('*.cbr|*.cbz|*.cbt'); 174 | const picker = new google.picker.PickerBuilder(). 175 | addView(docsView). 176 | // Enable this feature when we can efficiently get downloadUrls 177 | // for each file selected (right now we'd have to do drive.get 178 | // calls for each file which is annoying the way we have set up 179 | // library.allBooks). 180 | //enableFeature(google.picker.Feature.MULTISELECT_ENABLED). 181 | enableFeature(google.picker.Feature.NAV_HIDDEN). 182 | setOAuthToken(kthoom.google.oathToken). 183 | setDeveloperKey(kthoom.google.apiKey). 184 | setAppId(kthoom.google.clientId). 185 | setCallback(kthoom.google.pickerCallback). 186 | build(); 187 | picker.setVisible(true); 188 | }, 189 | 190 | pickerCallback(data) { 191 | if (data.action == google.picker.Action.PICKED) { 192 | const fullSize = data.docs[0].sizeBytes; 193 | const gRequest = gapi.client.drive.files.get({ 194 | 'fileId': data.docs[0].id, 195 | 'fields': 'webContentLink', 196 | }); 197 | gRequest.execute(function (response) { 198 | const bookName = data.docs[0].name; 199 | const fileId = data.docs[0].id; 200 | // NOTE: The CORS headers are not set properly on the webContentLink (URLs from 201 | // drive.google.com). See https://issuetracker.google.com/issues/149891169. 202 | const bookUrl = `https://www.googleapis.com/drive/v2/files/${fileId}?alt=media`; 203 | // Try to download using fetch, otherwise use XHR. 204 | try { 205 | const myHeaders = new Headers(); 206 | myHeaders.append('Authorization', 'OAuth ' + kthoom.google.oathToken); 207 | myHeaders.append('Origin', window.location.origin); 208 | const myInit = { 209 | method: 'GET', 210 | headers: myHeaders, 211 | mode: 'cors', 212 | cache: 'default', 213 | }; 214 | 215 | // TODO: This seems to pause a long time between making the first fetch and actually 216 | // starting to download. Show something in the UI? A spinner? 217 | kthoom.getApp().loadSingleBookFromFetch(bookName, bookUrl, fullSize, myInit); 218 | } catch (e) { 219 | if (typeof e === 'string' && e.startsWith('No browser support')) { 220 | kthoom.getApp().loadSingleBookFromXHR(bookName, bookUrl, fullSize, { 221 | 'Authorization': ('OAuth ' + kthoom.google.oathToken), 222 | }); 223 | } 224 | } 225 | }); 226 | } 227 | }, 228 | }; 229 | } 230 | 231 | (async function () { 232 | try { 233 | // Wait for everything to be loaded. 234 | await new Promise((resolve, reject) => { 235 | window.addEventListener('load', () => resolve()); 236 | }); 237 | 238 | // Load the Google API key if it exists. 239 | const gkey = await new Promise((resolve, reject) => { 240 | const xhr = new XMLHttpRequest(); 241 | xhr.open('GET', 'gkey.json', true); 242 | xhr.responseType = 'json'; 243 | xhr.onload = (evt) => { 244 | if (evt.target.status !== 200) { 245 | reject('gkey.json not found'); 246 | } 247 | resolve(evt.target.response); 248 | }; 249 | xhr.onerror = err => reject(err); 250 | xhr.send(null); 251 | }); 252 | 253 | if (!gkey['apiKey'] || !gkey['clientId']) { 254 | throw 'No API key or client ID found in gkey.json'; 255 | } 256 | 257 | const app = kthoom.getApp(); 258 | if (!app) { 259 | throw 'No kthoom app instance found'; 260 | } 261 | 262 | openMenu = app.getMenu('open'); 263 | if (!openMenu) { 264 | throw 'No Open menu found in the kthoom app'; 265 | } 266 | 267 | // If we get here, we know the hosted kthoom instance has a Google API Key, so 268 | // we can show the Open menu item. 269 | defineGoogleHooks(); 270 | kthoom.google.apiKey = gkey['apiKey']; 271 | kthoom.google.clientId = gkey['clientId']; 272 | openMenu.showMenuItem('menu-open-google-drive', true); 273 | } catch (err) { 274 | // Die. 275 | console.warn(`No Google Drive Integration: ${err}`); 276 | } 277 | })(); 278 | --------------------------------------------------------------------------------