File size: 15,436 Bytes
2ecdf7d 2c1d035 2ecdf7d 2c1d035 5084a8e 2ecdf7d 33faec5 2c1d035 33faec5 7105d63 33faec5 2ecdf7d 7105d63 5084a8e e16bd16 5084a8e 39fbaf7 5084a8e 39fbaf7 5084a8e 39fbaf7 5084a8e 39fbaf7 5084a8e 39fbaf7 5084a8e 2feb0de 46fa98c 5084a8e 46fa98c 1787788 46fa98c 1787788 46fa98c 2feb0de 39fbaf7 5084a8e f3244ea 2ecdf7d ac6d993 2ecdf7d f3244ea 2ecdf7d 39fbaf7 2ecdf7d 130ee5c 39fbaf7 2ecdf7d 39fbaf7 2ecdf7d 78482d8 39fbaf7 78482d8 39fbaf7 c9913fc 2ecdf7d f3244ea 2ecdf7d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 | const fs = require('fs')
const path = require('path')
const assert = require('assert')
const vm = require('vm')
const { TextEncoder } = require('util')
const { assertCode, readReaderSource } = require('./reader_source_contract')
const root = path.resolve(__dirname, '..')
const reader = readReaderSource(root)
const contract = fs.readFileSync(path.join(root, 'static/reader/core/reader-contract.js'), 'utf8')
const sandbox = { self: {}, TextEncoder, URLSearchParams, URL }
vm.runInNewContext(contract, sandbox)
const pdfText = {}
vm.runInNewContext(fs.readFileSync(path.join(root, 'static/reader/pdf/reader-pdf-text.js'), 'utf8').replace(/^export /gm, ''), pdfText)
for (const [raw, expected] of [
['', ''], ['hello world', 'hello world'], ['a\n b', 'a\n b'],
['\u624b \u673a', '\u624b\u673a'], ['\u624b\u3002\u673a', '\u624b\u3002\u673a'],
['\u6bdb \u6cfd\n\u4e1c hello world', '\u6bdb\u6cfd\u4e1c hello world'],
['\ud840\udc00 \ud840\udc01', '\ud840\udc00\ud840\udc01'],
['\u624b\t\n \u673a a b \u624b \u673a', '\u624b\u673a a b \u624b\u673a'],
]) {
for (let repeat = 0; repeat < 3; repeat++) {
assert.strictEqual(pdfText.normalizePdfSearchText(raw), expected)
const mapped = pdfText.pdfSearchText(raw)
assert.strictEqual(mapped.text, expected)
assert.strictEqual(Array.from({length: mapped.text.length}, (_, index) => raw[pdfText.pdfSourceOffset(mapped, index)]).join(''), expected)
}
}
const unchanged = pdfText.pdfSearchText('unchanged text '.repeat(10000))
assert.strictEqual(unchanged.offsets, null)
const sparse = pdfText.pdfSearchText('prefix '.repeat(10000) + '\u624b \u673a' + ' suffix'.repeat(10000))
assert.strictEqual(sparse.offsets.byteLength, 8)
for (const raw of ['\u624b \u673a \u624b\n\u673a', '\ud840\udc00 \ud840\udc01\r\n\u624b \u673a']) {
const deleted = new Set()
for (const match of raw.matchAll(/(?<=\p{Script=Han})\s+(?=\p{Script=Han})/gu))
for (let i=match.index;i<match.index+match[0].length;i++) deleted.add(i)
const oracle = Array.from({length:raw.length}, (_,i)=>i).filter(i=>!deleted.has(i))
const mapped = pdfText.pdfSearchText(raw)
assert.deepStrictEqual(Array.from({length:mapped.text.length}, (_,i)=>pdfText.pdfSourceOffset(mapped,i)), oracle)
}
const assets = sandbox.self.VoiceOfMLReader
const vectors = require('./reader-contract-vectors.json')
for (const [value, expected] of vectors.ids) assert.strictEqual(assets.shortSourceId(value), expected)
const idPrefix = 'https://huggingface.co/datasets/VoiceOfML/VOMEBOOK/resolve/main/'
for (const [value, expected] of vectors.canonicalPaths) {
assert.strictEqual(assets.canonicalSourceUrl(idPrefix + value), idPrefix + expected)
assert.strictEqual(assets.canonicalSourceUrl(idPrefix + expected), idPrefix + expected)
assert.strictEqual(assets.shortSourceId(idPrefix + value), assets.shortSourceId(idPrefix + expected))
}
for (const bookPath of [vectors.reportedBook.path, vectors.reportedBook.path.split('/').map(encodeURIComponent).join('/')]) {
const link = idPrefix + bookPath
assert.strictEqual(assets.shortSourceId(link), vectors.reportedBook.id)
assert.strictEqual(new URL(assets.readerUrl({ Link: link, Extension: 'pdf' }), 'https://site.test').searchParams.get('id'), vectors.reportedBook.id)
}
assert.notStrictEqual(assets.shortSourceId(idPrefix + 'a%2Fb.pdf'), assets.shortSourceId(idPrefix + 'a/b.pdf'))
assert.notStrictEqual(assets.shortSourceId(idPrefix + 'a%2528.pdf'), assets.shortSourceId(idPrefix + 'a%28.pdf'))
for (const [record, expected] of vectors.ocr) {
const relative = [...record.Folder, record.File + (record.Extension ? '.' + record.Extension : '')].join('/')
assert.strictEqual(assets.txtRelativePath(relative), expected)
}
const assetRoot = 'objects/aa/' + 'a'.repeat(64) + '/'
const legacyPrefix = '/datasets/vomebook/Reader-Assets/resolve/main/' // deleted dataset, kept only as a negative fixture
// Dataset-era object paths without a bucket are no longer readable.
for (const directory of ['', 'converter-v1/', 'b'.repeat(16) + '/', 'b'.repeat(16) + '/converter-v1/']) {
const path = assetRoot + directory + 'linearized.pdf'
assert.strictEqual(assets.assetFields({s: 2, m: 'p', p: path}, '/api/reader-bucket-resource'), null)
}
for (const extension of ['epub', 'mobi', 'azw3', 'fb2', 'chm']) {
const path = 'chapters/ebook/' + extension + '/' + 'c'.repeat(64) + '/' + 'b'.repeat(16) + '/chapter-manifest.json'
const fields = assets.assetFields({s: 2, m: 'e', p: path, b: 'vomebook/reader-assets-v2',
c: path, cb: 'vomebook/reader-assets-v2'}, '/api/reader-bucket-resource')
assert.strictEqual(fields.ReaderExtension, 'epub-chapters')
assert.strictEqual(fields.ReaderLink, '/api/reader-bucket-resource?path=' + encodeURIComponent(path))
}
const bucket = assetRoot + 'b'.repeat(16) + '/page-manifest.json'
for (const suffix of ['b'.repeat(16) + '/']) {
const url = 'https://huggingface.co/buckets/vomebook/pdf-pages-v2/resolve/' + assetRoot + suffix + 'page-manifest.json'
const parsed = assets.pdfPageSource(url, 'https://site.test', 'https://api.test')
assert.strictEqual(parsed.root, (assetRoot + suffix).slice(0, -1))
assert.strictEqual(parsed.pageUrl(2), url.replace('page-manifest.json', 'pages/page-000002.webp'))
assert.throws(() => parsed.pageUrl(0), /PDF_PAGE_INVALID/)
}
assert.ok(assets.pdfPageSource('https://api.test/api/reader-bucket-resource?path=' + encodeURIComponent(bucket), 'https://site.test', 'https://api.test'))
for (const url of [
'https://evil.test/buckets/vomebook/pdf-pages-v2/resolve/' + bucket,
'https://api.test/api/reader-bucket-resource?path=' + encodeURIComponent(assetRoot + 'page-manifest.json'),
])
assert.strictEqual(assets.pdfPageSource(url, 'https://site.test', 'https://api.test'), null)
// The deleted dataset form is no longer a source for any Reader surface.
for (const url of [
'https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/' + bucket,
'https://hf-mirror.com/datasets/vomebook/Reader-Assets/revision/main/' + bucket,
])
assert.strictEqual(assets.pdfPageSource(url, 'https://site.test', 'https://api.test'), null)
assert.strictEqual(assets.assetFields({s: 2, m: 'p', p: bucket}, '/api/reader-bucket-resource').ReaderLink,
'/api/reader-bucket-resource?path=' + encodeURIComponent(bucket))
assert.strictEqual(assets.assetFields({s: 2, m: 'p', p: bucket, b: 'vomebook/pdf-pages-v2'}, '/api/reader-bucket-resource').ReaderLink,
'/api/reader-bucket-resource?path=' + encodeURIComponent(bucket))
const nativeMediaPath = 'media/audio/native/' + 'a'.repeat(64) + '/audio.flac'
const nativeMedia = assets.assetFields({s: 2, m: 'a', p: nativeMediaPath, b: 'vomebook/reader-assets-v2'}, '/api/reader-bucket-resource')
assert.strictEqual(nativeMedia.ReaderExtension, 'audio')
assert.strictEqual(nativeMedia.ReaderLink,
'/api/reader-bucket-resource?path=' + encodeURIComponent(nativeMediaPath))
assert.strictEqual(assets.isBucketPath(nativeMediaPath, true, 'vomebook/reader-assets-v2'), true)
const swfPath = 'media/swf/native/' + 'a'.repeat(64) + '/document.swf'
const swf = assets.assetFields({s: 2, m: 'f', p: swfPath, b: 'vomebook/reader-assets-v2'}, '/api/reader-bucket-resource')
assert.strictEqual(swf.ReaderExtension, 'swf')
assert.strictEqual(assets.capability('swf').mode, 'swf')
assert.strictEqual(swf.ReaderLink,
'/api/reader-bucket-resource?path=' + encodeURIComponent(swfPath))
for (const path of ['derived/migration/' + 'a'.repeat(32) + '/document.pdf', 'documents/pdf/ppt/' + 'a'.repeat(64) + '/document.pdf']) {
const fields = assets.assetFields({s: 2, m: 'p', p: path, b: path.startsWith('derived/') ? 'vomebook/pdf-pages-v2' : 'vomebook/reader-assets-v2'}, '/api/reader-bucket-resource')
assert.strictEqual(fields.ReaderLink, '/api/reader-bucket-resource?path=' + encodeURIComponent(path))
assert.strictEqual(fields.ReaderExtension, 'pdf')
}
assert.strictEqual(assets.assetFields({s: 2, m: 'p', p: bucket, b: 'vomebook/pdf-pages'}), null)
const optimized = assetRoot + 'pdf-range-v1-document/document.pdf'
assert.strictEqual(assets.assetFields({s: 2, m: 'p', p: optimized, b: 'vomebook/pdf-optimized'}), null)
assert.strictEqual(assets.assetFields({s: 2, m: 'p', p: legacyPrefix + 'pdf_manifest.json'}), null)
assert.strictEqual(assets.assetFields({s: 2, m: 'p', p: legacyPrefix + assetRoot + 'epub-chapters/chapters/chapter-0012.xhtml'}), null)
for (const path of [assetRoot + '../linearized.pdf', assetRoot + 'private.json', 'objects/invalid/document.pdf']) {
assert.strictEqual(assets.assetFields({s: 2, m: 'p', p: path}), null)
}
for (const extension of ['pdf', 'pdf-pages', 'txt', 'md', 'markdown', 'html', 'htm', 'epub', 'mobi', 'azw3', 'fb2', 'fbz', 'jpg', 'png', 'mp3', 'm4a', 'flac', 'mpga', 'audio', 'mp4', 'mov', 'video']) {
assert.ok(sandbox.self.VoiceOfMLReader.capability(extension).mode, extension)
}
for (const extension of ['epub', 'mobi', 'azw3', 'fb2', 'fbz']) assert.strictEqual(sandbox.self.VoiceOfMLReader.capability(extension).mode, 'foliate')
for (const extension of sandbox.self.VoiceOfMLReader.articleExtensions) assert.deepStrictEqual(Object.keys(sandbox.self.VoiceOfMLReader.capability(extension).features).sort(), ['bookmarks', 'media', 'pagination', 'search', 'toc', 'zoom'])
assert.strictEqual(sandbox.self.VoiceOfMLReader.capability('pdf-pages').mode, 'pdf-pages')
assert.strictEqual(sandbox.self.VoiceOfMLReader.capability('pdf-pages').features.toc, true)
assert.strictEqual(sandbox.self.VoiceOfMLReader.capability('epub-chapters').features.search, true)
for (const extension of ['mp3', 'm4a', 'flac', 'mpga', 'audio']) assert.strictEqual(sandbox.self.VoiceOfMLReader.capability(extension).mode, 'audio')
for (const extension of ['mp4', 'mov', 'video']) assert.strictEqual(sandbox.self.VoiceOfMLReader.capability(extension).mode, 'video')
assert.strictEqual(sandbox.self.VoiceOfMLReader.capability('zip').mode, null)
assert.strictEqual(sandbox.self.VoiceOfMLReader.capability('docx').mode, null)
assert.strictEqual(sandbox.self.VoiceOfMLReader.capability('epub-chapters').mode, 'epub-chapters')
const convertedUrl = sandbox.self.VoiceOfMLReader.readerUrl({
Link: 'https://download.example.test/books/book.mobi',
File: '原始书名', Extension: 'mobi', ReaderExtension: 'epub',
DownloadLink: 'https://download.example.test/books/book.mobi',
ReaderChapterManifest: 'https://voiceofml-search.hf.space/api/reader-bucket-resource?path=' + encodeURIComponent('chapters/ebook/mobi/' + 'c'.repeat(64) + '/' + 'b'.repeat(16) + '/chapter-manifest.json'),
}, '/reader.html')
const convertedParams = new URL(`https://example.test${convertedUrl}`).searchParams
assert.strictEqual(convertedParams.get('ext'), 'epub')
assert.strictEqual(convertedParams.get('title'), '原始书名.mobi')
assert.strictEqual(convertedParams.get('download'), 'https://download.example.test/books/book.mobi')
assert.strictEqual(convertedParams.get('fallback'), null)
assert.strictEqual(convertedParams.get('chapter_manifest'), 'https://voiceofml-search.hf.space/api/reader-bucket-resource?path=' + encodeURIComponent('chapters/ebook/mobi/' + 'c'.repeat(64) + '/' + 'b'.repeat(16) + '/chapter-manifest.json'))
const pageAssetUrl = sandbox.self.VoiceOfMLReader.readerUrl({
ReaderLink: 'https://voiceofml-search.hf.space/api/reader-bucket-resource?path=' + encodeURIComponent('objects/aa/' + 'a'.repeat(64) + '/1234567890abcdef/page-manifest.json'),
ReaderExtension: 'pdf-pages', Extension: 'mobi', File: '原始书名',
})
const pageAssetParams = new URL(`https://example.test${pageAssetUrl}`).searchParams
assert.strictEqual(pageAssetParams.get('url'), 'https://voiceofml-search.hf.space/api/reader-bucket-resource?path=' + encodeURIComponent('objects/aa/' + 'a'.repeat(64) + '/1234567890abcdef/page-manifest.json'))
assert.strictEqual(pageAssetParams.get('ext'), 'pdf-pages')
const sharedAsset = 'https://voiceofml-search.hf.space/api/reader-bucket-resource?path=' + encodeURIComponent('chapters/ebook/chm/' + 'a'.repeat(64) + '/' + 'b'.repeat(16) + '/chapter-manifest.json')
const sourceIds = ['Teachers', 'MLMRL-Hub'].map(repo => {
const record = { Link: `https://huggingface.co/datasets/VoiceOfML/${repo}/resolve/main/book.mobi`, File: 'book', Extension: 'mobi' }
const params = value => new URL(`https://example.test${sandbox.self.VoiceOfMLReader.readerUrl(value)}`).searchParams
const original = params(record).get('id')
const converted = params({ ...record, ReaderLink: sharedAsset, ReaderExtension: 'epub-chapters' })
assert.strictEqual(converted.get('id'), original)
assert.match(original, /^[a-z0-9]{13}$/)
assert.strictEqual(converted.get('ext'), 'epub-chapters')
return original
})
assert.notStrictEqual(sourceIds[0], sourceIds[1])
const normalizedUrl = sandbox.self.VoiceOfMLReader.readerUrl({ Link: 'https://download.example.test/book.TXT', File: '书名', Extension: 'TXT', ReaderExtension: 'TXT' }, '/reader.html')
const normalizedParams = new URL(`https://example.test${normalizedUrl}`).searchParams
assert.strictEqual(normalizedParams.get('ext'), 'TXT')
assert.strictEqual(normalizedParams.get('title'), '书名.TXT')
assert.strictEqual(sandbox.self.VoiceOfMLReader.readerUrl(null, '/reader.html'), '')
assert.strictEqual(sandbox.self.VoiceOfMLReader.readerUrl({ Link: 'https://download.example.test/archive.zip', Extension: 'zip' }, '/reader.html'), '')
assert.strictEqual(sandbox.self.VoiceOfMLReader.readerUrl({ Link: 'https://download.example.test/book.txt', Extension: '.txt' }, '/reader.html'), '')
assert.strictEqual(sandbox.self.VoiceOfMLReader.capability('PDF').readerMode, sandbox.self.VoiceOfMLReader.ReaderMode.ORIGINAL)
assert.strictEqual(sandbox.self.VoiceOfMLReader.capability('PDF').article, true)
assert.strictEqual(sandbox.self.VoiceOfMLReader.capability('zip').readerMode, sandbox.self.VoiceOfMLReader.ReaderMode.UNSUPPORTED)
assert.strictEqual(sandbox.self.VoiceOfMLReader.capability('zip').article, false)
for (const extension of ['pdf', 'pdf-pages', 'epub', 'epub-chapters', 'mobi', 'azw3', 'fb2', 'fbz', 'txt', 'md', 'html', 'jpg', 'png', 'webp', 'mp3', 'm4a', 'flac', 'mp4', 'mov']) assert.ok(sandbox.self.VoiceOfMLReader.articleExtensions.includes(extension), extension)
assert.ok(!sandbox.self.VoiceOfMLReader.articleExtensions.includes('docx'))
assert.strictEqual(Object.isFrozen(sandbox.self.VoiceOfMLReader.articleExtensions), true)
assert.strictEqual(Object.isFrozen(sandbox.self.VoiceOfMLReader.capability('pdf')), true)
const immutableRecord = { Link: 'https://download.example.test/book.txt', File: 'book', Extension: 'txt' }
const immutableBefore = JSON.stringify(immutableRecord)
const defaultReaderUrl = sandbox.self.VoiceOfMLReader.readerUrl(immutableRecord)
assert.ok(defaultReaderUrl.startsWith('/static/reader.html?'))
assert.strictEqual(JSON.stringify(immutableRecord), immutableBefore)
assert.strictEqual((reader.match(/function setToc\(/g) || []).length, 1)
assertCode(reader, 'const previousList = document.querySelector("#toc-list"), list = previousList.cloneNode(false)')
assertCode(reader, 'previousList.replaceWith(list)')
assert.strictEqual((reader.match(/function activateFoliateTocEntry\(/g) || []).length, 1)
assert.match(reader, /reader-source:/)
assert.match(reader, /reader-zoom/)
assert.match(reader, /readerRuntime\.track\(readerRequestManager\)/)
assert.match(reader, /readerPath[\s\S]*voice-reader-navigate/)
assert.match(reader, /title: historyTitle|title,\s*extension/)
console.log('reader regression contracts passed')
|