umdParser.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  1. import { inflate, inflateRaw } from 'pako'
  2. const UMD_MAGIC = 0xde9a9b89
  3. const SECTION_MARKER = 0x23
  4. const DATA_MARKER = 0x24
  5. const MAX_SECTION_HEADER_SIZE = 5
  6. const UTF16_DECODER = new TextDecoder('utf-16le')
  7. const SECTION = {
  8. VERSION: 0x01,
  9. TITLE: 0x02,
  10. AUTHOR: 0x03,
  11. YEAR: 0x04,
  12. MONTH: 0x05,
  13. DAY: 0x06,
  14. CATEGORY: 0x07,
  15. PUBLISHER: 0x08,
  16. VENDOR: 0x09,
  17. CID: 0x0a,
  18. CONTENT_LENGTH: 0x0b,
  19. IMAGE: 0x0e,
  20. MIXED_IMAGE: 0x0f,
  21. TEXT_SEGMENT_INDEX: 0x81,
  22. COVER: 0x82,
  23. CHAPTER_OFFSETS: 0x83,
  24. CHAPTER_TITLES: 0x84,
  25. PAGE_OFFSETS: 0x87,
  26. SPLASH: 0xf1
  27. } as const
  28. export type UmdBookKind = 'text' | 'comic' | 'mixed' | 'unknown'
  29. export interface UmdImage {
  30. bytes: Uint8Array
  31. extension: string
  32. id: string
  33. mimeType: string
  34. }
  35. export interface UmdChapter {
  36. content: string
  37. end: number
  38. id: string
  39. images: UmdImage[]
  40. start: number
  41. title: string
  42. }
  43. export interface UmdBook {
  44. author: string
  45. category: string
  46. chapters: UmdChapter[]
  47. contentLength: number
  48. cover?: UmdImage
  49. kind: UmdBookKind
  50. publishedAt: string
  51. publisher: string
  52. rawType: number
  53. title: string
  54. vendor: string
  55. warnings: string[]
  56. }
  57. type UmdMetadata = {
  58. author: string
  59. category: string
  60. day: string
  61. month: string
  62. publishedAt: string
  63. publisher: string
  64. title: string
  65. vendor: string
  66. year: string
  67. }
  68. class UmdCursor {
  69. private readonly view: DataView
  70. readonly bytes: Uint8Array
  71. offset = 0
  72. constructor(buffer: ArrayBuffer) {
  73. this.bytes = new Uint8Array(buffer)
  74. this.view = new DataView(buffer)
  75. }
  76. get remaining() {
  77. return this.bytes.length - this.offset
  78. }
  79. peek() {
  80. return this.remaining > 0 ? this.bytes[this.offset] : undefined
  81. }
  82. readUint8() {
  83. this.ensure(1)
  84. const value = this.view.getUint8(this.offset)
  85. this.offset += 1
  86. return value
  87. }
  88. readUint16() {
  89. this.ensure(2)
  90. const value = this.view.getUint16(this.offset, true)
  91. this.offset += 2
  92. return value
  93. }
  94. readUint32() {
  95. this.ensure(4)
  96. const value = this.view.getUint32(this.offset, true)
  97. this.offset += 4
  98. return value
  99. }
  100. readBytes(length: number) {
  101. const safeLength = Math.max(0, Math.min(length, this.remaining))
  102. const start = this.offset
  103. this.offset += safeLength
  104. return this.bytes.slice(start, start + safeLength)
  105. }
  106. private ensure(size: number) {
  107. if (this.remaining < size) {
  108. throw new Error('UMD 文件结构不完整,读取时遇到意外结尾')
  109. }
  110. }
  111. }
  112. const readUint32From = (bytes: Uint8Array, offset = 0) => {
  113. if (bytes.length < offset + 4) {
  114. return 0
  115. }
  116. return new DataView(bytes.buffer, bytes.byteOffset + offset, 4).getUint32(0, true)
  117. }
  118. const decodeUtf16 = (bytes: Uint8Array) => {
  119. if (!bytes.length) {
  120. return ''
  121. }
  122. return UTF16_DECODER.decode(bytes)
  123. }
  124. const decodeMetadata = (bytes: Uint8Array) => {
  125. return decodeUtf16(bytes)
  126. .replace(/\u0000+$/g, '')
  127. .trim()
  128. }
  129. const normalizeContent = (value: string) => {
  130. return value
  131. .replace(/\u0000+$/g, '')
  132. .replace(/\u2029/g, '\n')
  133. .replace(/\r\n?/g, '\n')
  134. }
  135. const joinByteArrays = (parts: Uint8Array[], expectedLength = 0) => {
  136. const totalLength = parts.reduce((sum, part) => sum + part.length, 0)
  137. const length = expectedLength > 0 ? Math.min(expectedLength, totalLength) : totalLength
  138. const result = new Uint8Array(length)
  139. let offset = 0
  140. for (const part of parts) {
  141. if (offset >= length) {
  142. break
  143. }
  144. const next = part.subarray(0, Math.min(part.length, length - offset))
  145. result.set(next, offset)
  146. offset += next.length
  147. }
  148. return result
  149. }
  150. const inflateSegment = (bytes: Uint8Array) => {
  151. try {
  152. return inflate(bytes)
  153. } catch {
  154. return inflateRaw(bytes)
  155. }
  156. }
  157. const parseChapterTitles = (bytes: Uint8Array) => {
  158. const titles: string[] = []
  159. let offset = 0
  160. while (offset < bytes.length) {
  161. const length = bytes[offset]
  162. offset += 1
  163. if (!length || offset + length > bytes.length) {
  164. break
  165. }
  166. titles.push(decodeMetadata(bytes.subarray(offset, offset + length)))
  167. offset += length
  168. }
  169. return titles
  170. }
  171. const parseOffsets = (bytes: Uint8Array) => {
  172. const offsets: number[] = []
  173. const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
  174. for (let offset = 0; offset + 4 <= bytes.length; offset += 4) {
  175. offsets.push(view.getUint32(offset, true))
  176. }
  177. return offsets
  178. }
  179. const detectImage = (bytes: Uint8Array) => {
  180. if (bytes.length >= 8 &&
  181. bytes[0] === 0x89 &&
  182. bytes[1] === 0x50 &&
  183. bytes[2] === 0x4e &&
  184. bytes[3] === 0x47) {
  185. return { extension: 'png', mimeType: 'image/png' }
  186. }
  187. if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
  188. return { extension: 'jpg', mimeType: 'image/jpeg' }
  189. }
  190. if (bytes.length >= 6 &&
  191. bytes[0] === 0x47 &&
  192. bytes[1] === 0x49 &&
  193. bytes[2] === 0x46) {
  194. return { extension: 'gif', mimeType: 'image/gif' }
  195. }
  196. if (bytes.length >= 2 && bytes[0] === 0x42 && bytes[1] === 0x4d) {
  197. return { extension: 'bmp', mimeType: 'image/bmp' }
  198. }
  199. if (bytes.length >= 12 &&
  200. bytes[0] === 0x52 &&
  201. bytes[1] === 0x49 &&
  202. bytes[2] === 0x46 &&
  203. bytes[3] === 0x46 &&
  204. bytes[8] === 0x57 &&
  205. bytes[9] === 0x45 &&
  206. bytes[10] === 0x42 &&
  207. bytes[11] === 0x50) {
  208. return { extension: 'webp', mimeType: 'image/webp' }
  209. }
  210. return { extension: 'bin', mimeType: 'application/octet-stream' }
  211. }
  212. const createImage = (bytes: Uint8Array, prefix: string, index: number): UmdImage => {
  213. const { extension, mimeType } = detectImage(bytes)
  214. return {
  215. bytes,
  216. extension,
  217. id: `${prefix}-${index}-${bytes.length}`,
  218. mimeType
  219. }
  220. }
  221. const toBookKind = (rawType: number): UmdBookKind => {
  222. if (rawType === 1) {
  223. return 'text'
  224. }
  225. if (rawType === 2) {
  226. return 'comic'
  227. }
  228. if (rawType === 3) {
  229. return 'mixed'
  230. }
  231. return 'unknown'
  232. }
  233. const formatPublishedAt = ({ day, month, year }: UmdMetadata) => {
  234. const parts = [year, month, day].filter(Boolean)
  235. return parts.join('-')
  236. }
  237. const createTextChapters = (textBytes: Uint8Array, chapterOffsets: number[], titles: string[]) => {
  238. if (!textBytes.length) {
  239. return []
  240. }
  241. const offsets = chapterOffsets.length ? chapterOffsets : [0]
  242. const safeOffsets = offsets
  243. .map(offset => Math.max(0, Math.min(offset, textBytes.length)))
  244. .filter((offset, index, list) => index === 0 || offset > list[index - 1])
  245. return safeOffsets.map((start, index) => {
  246. const end = index + 1 < safeOffsets.length ? safeOffsets[index + 1] : textBytes.length
  247. const alignedStart = start - (start % 2)
  248. const alignedEnd = end - (end % 2)
  249. const content = normalizeContent(decodeUtf16(textBytes.subarray(alignedStart, alignedEnd)))
  250. return {
  251. content,
  252. end: alignedEnd,
  253. id: `chapter-${index}-${alignedStart}`,
  254. images: [],
  255. start: alignedStart,
  256. title: titles[index] || `章节 ${index + 1}`
  257. }
  258. })
  259. }
  260. const createImageChapters = (images: UmdImage[], chapterOffsets: number[], titles: string[]) => {
  261. if (!images.length) {
  262. return []
  263. }
  264. const offsets = chapterOffsets.length ? chapterOffsets : [0]
  265. const safeOffsets = offsets
  266. .map(offset => Math.max(0, Math.min(offset, images.length)))
  267. .filter((offset, index, list) => index === 0 || offset > list[index - 1])
  268. return safeOffsets.map((start, index) => {
  269. const end = index + 1 < safeOffsets.length ? safeOffsets[index + 1] : images.length
  270. return {
  271. content: '',
  272. end,
  273. id: `image-chapter-${index}-${start}`,
  274. images: images.slice(start, end),
  275. start,
  276. title: titles[index] || `图集 ${index + 1}`
  277. }
  278. })
  279. }
  280. const createEmptyChapters = (titles: string[]) => {
  281. return titles.map((title, index) => ({
  282. content: '',
  283. end: index,
  284. id: `empty-chapter-${index}`,
  285. images: [],
  286. start: index,
  287. title: title || `章节 ${index + 1}`
  288. }))
  289. }
  290. export const parseUmdBook = (buffer: ArrayBuffer): UmdBook => {
  291. const cursor = new UmdCursor(buffer)
  292. const metadata: UmdMetadata = {
  293. author: '',
  294. category: '',
  295. day: '',
  296. month: '',
  297. publishedAt: '',
  298. publisher: '',
  299. title: '',
  300. vendor: '',
  301. year: ''
  302. }
  303. const sectionChecks = new Map<number, number>()
  304. const warnings: string[] = []
  305. const textSegments: Uint8Array[] = []
  306. const images: UmdImage[] = []
  307. let activeSection = 0
  308. let chapterOffsets: number[] = []
  309. let chapterTitles: string[] = []
  310. let contentLength = 0
  311. let cover: UmdImage | undefined
  312. let rawType = 0
  313. if (cursor.readUint32() !== UMD_MAGIC) {
  314. throw new Error('不是有效的 UMD 电子书文件')
  315. }
  316. while (cursor.remaining > 0) {
  317. if (cursor.peek() !== SECTION_MARKER) {
  318. break
  319. }
  320. cursor.readUint8()
  321. const sectionType = cursor.readUint16()
  322. const sectionFlag = cursor.readUint8()
  323. const sectionLength = Math.max(0, cursor.readUint8() - MAX_SECTION_HEADER_SIZE)
  324. const payload = cursor.readBytes(sectionLength)
  325. const dataSection = sectionType === SECTION.CID || sectionType === SECTION.SPLASH
  326. ? activeSection
  327. : sectionType
  328. switch (sectionType) {
  329. case SECTION.VERSION:
  330. rawType = payload[0] || sectionFlag
  331. break
  332. case SECTION.TITLE:
  333. metadata.title = decodeMetadata(payload)
  334. break
  335. case SECTION.AUTHOR:
  336. metadata.author = decodeMetadata(payload)
  337. break
  338. case SECTION.YEAR:
  339. metadata.year = decodeMetadata(payload)
  340. break
  341. case SECTION.MONTH:
  342. metadata.month = decodeMetadata(payload)
  343. break
  344. case SECTION.DAY:
  345. metadata.day = decodeMetadata(payload)
  346. break
  347. case SECTION.CATEGORY:
  348. metadata.category = decodeMetadata(payload)
  349. break
  350. case SECTION.PUBLISHER:
  351. metadata.publisher = decodeMetadata(payload)
  352. break
  353. case SECTION.VENDOR:
  354. metadata.vendor = decodeMetadata(payload)
  355. break
  356. case SECTION.CONTENT_LENGTH:
  357. contentLength = readUint32From(payload)
  358. break
  359. case SECTION.TEXT_SEGMENT_INDEX:
  360. case SECTION.CHAPTER_OFFSETS:
  361. case SECTION.CHAPTER_TITLES:
  362. case SECTION.PAGE_OFFSETS:
  363. sectionChecks.set(sectionType, readUint32From(payload))
  364. break
  365. case SECTION.COVER:
  366. sectionChecks.set(sectionType, readUint32From(payload, 1))
  367. break
  368. case SECTION.IMAGE:
  369. case SECTION.MIXED_IMAGE:
  370. rawType = rawType || (sectionType === SECTION.MIXED_IMAGE ? 3 : 2)
  371. break
  372. default:
  373. break
  374. }
  375. activeSection = dataSection || activeSection
  376. while (cursor.peek() === DATA_MARKER) {
  377. cursor.readUint8()
  378. const check = cursor.readUint32()
  379. const dataLength = Math.max(0, cursor.readUint32() - 9)
  380. const data = cursor.readBytes(dataLength)
  381. switch (dataSection) {
  382. case SECTION.COVER:
  383. cover = createImage(data, 'cover', check)
  384. break
  385. case SECTION.CHAPTER_OFFSETS:
  386. chapterOffsets = parseOffsets(data)
  387. break
  388. case SECTION.CHAPTER_TITLES:
  389. if (check === sectionChecks.get(SECTION.CHAPTER_TITLES)) {
  390. chapterTitles = parseChapterTitles(data)
  391. } else {
  392. textSegments.push(data)
  393. }
  394. break
  395. case SECTION.IMAGE:
  396. case SECTION.MIXED_IMAGE:
  397. images.push(createImage(data, 'image', images.length))
  398. break
  399. default:
  400. break
  401. }
  402. }
  403. }
  404. const inflatedText = textSegments.map(segment => {
  405. try {
  406. return inflateSegment(segment)
  407. } catch (error) {
  408. warnings.push(error instanceof Error ? error.message : String(error))
  409. return new Uint8Array()
  410. }
  411. })
  412. const joinedText = joinByteArrays(inflatedText, contentLength)
  413. if (contentLength > joinedText.length && textSegments.length) {
  414. warnings.push('UMD 正文长度小于声明长度,文件可能不完整')
  415. }
  416. let chapters: UmdChapter[] = createTextChapters(joinedText, chapterOffsets, chapterTitles)
  417. if (!chapters.length) {
  418. chapters = createImageChapters(images, chapterOffsets, chapterTitles)
  419. } else if (images.length && rawType !== 1) {
  420. chapters = createImageChapters(images, chapterOffsets, chapterTitles)
  421. }
  422. if (!chapters.length && chapterTitles.length) {
  423. chapters = createEmptyChapters(chapterTitles)
  424. }
  425. metadata.publishedAt = formatPublishedAt(metadata)
  426. return {
  427. author: metadata.author,
  428. category: metadata.category,
  429. chapters,
  430. contentLength: contentLength || joinedText.length,
  431. cover,
  432. kind: toBookKind(rawType),
  433. publishedAt: metadata.publishedAt,
  434. publisher: metadata.publisher,
  435. rawType,
  436. title: metadata.title || 'UMD 电子书',
  437. vendor: metadata.vendor,
  438. warnings
  439. }
  440. }