V1.0.46 oss (#253)

* add a UI trigger for demo annotations

* Implement internal link navigation

* Simplify bottom padding logic in `EpubReaderScreen` for vertical scroll mode

* Add support for Calibre metadata

* Implement advanced library management with Room-backed collections, tags, and smart rules.

* Display book series information in book details and remove tags from home screen

* Implement zoom reset functionality in PDF viewer

* Add a "Pages" thumbnail view to the PDF navigation drawer

* Implement jump-back navigation in PDF viewer

* MainViewModel: disable FolderSyncWorker, add log export, and enhance PDF position logging

* Replace Gson with Kotlin Serialization in SmartCollectionEngine

* Implement ONNX-based speech bubble detection

* Implement speech bubble detection in PDF viewer

* Implement "Smart Comic Zoom" feature for PDF manga/comic reading

* Implement reader session persistence and restoration in `MainViewModel`

* Add tap-to-turn page feature to PDF viewer

* Improve book cache management and recovery

* Refactor top overlay padding logic in `PdfViewerScreen`

* Implement stylus eraser support in PDF reader

* Refactor ReaderTextFormatPanel to use ModalBottomSheet and add new formatting controls

* Introduce a customizable horizontal margin setting for the EPUB reader

* Refine folder sync and metadata handling for better conflict resolution and stability.

* Introduce user-adjustable image scaling for the EPUB reader

* Use maxWidthPx as default width fallback for blocks

* Improve cross-page text selection and header styling in the paginated reader

* Refactor speech bubble detection and UI to support segmentation masks and interactive scaling

* Improve speech bubble detection masking and rendering quality

* Update ONNX speech bubble detector to use `.ort` model and optimize inference

* Improve pagination accuracy

* Implement hierarchical folder navigation for the library.

* Refactor library item layout for improved space efficiency

* Add support for browsing and downloading Google Fonts

* Persist library landing state and add shelf search functionality

* Fix base tts sample.

* Move SpeechBubbleDetector to main source set and update ONNX dependency

* Implement a "locate" feature and improve synchronization for TTS (Text-to-Speech) playback across EPUB and PDF readers.

* Refine TTS synchronization and voice management in the EPUB reader

* Adjust OCR checks for OSS flavor and optimize external dictionary intent flags

* Add "Locate" button to PDF drawer's pages tab and move the page number to bottom right of thumbnail

* fix various crashes

* Refactor SpeechBubbleDetector into product flavors

* Implement speech bubble detection caching and background prefetching

* Implement on-demand download for Bubble Zoom ML model

* Add smooth animation for PDF speech bubble expansion

* fixes #252

* hide Google Fonts option, in FontsScreen, for offline variant

* Bump version to 1.0.46(46)
This commit is contained in:
Aryan 2026-04-29 16:37:53 +05:30 committed by GitHub
parent 579b6f25bf
commit d16bdd70d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 9724 additions and 1890 deletions

View file

@ -119,7 +119,8 @@ class BookPaginator(
private val context: Context,
private val mathMLRenderer: MathMLRenderer,
private val userTextAlign: TextAlign?,
private val paragraphGapMultiplier: Float
private val paragraphGapMultiplier: Float,
private val imageSizeMultiplier: Float
) : IPaginator {
override var totalPageCount by mutableIntStateOf(0)
private set
@ -267,7 +268,16 @@ class BookPaginator(
}
private fun generateConfigurationHash(): Int {
val configString = "w:${constraints.maxWidth}-h:${constraints.maxHeight}-fs:${textStyle.fontSize.value}-ta:$userTextAlign-pg:$paragraphGapMultiplier"
val configString = buildString {
append("w:${constraints.maxWidth}")
append("-h:${constraints.maxHeight}")
append("-fs:${textStyle.fontSize.value}")
append("-lh:${textStyle.lineHeight.value}")
append("-ff:${textStyle.fontFamily}")
append("-ta:$userTextAlign")
append("-pg:$paragraphGapMultiplier")
append("-img:$imageSizeMultiplier")
}
val hash = configString.hashCode()
return hash
}
@ -722,7 +732,8 @@ class BookPaginator(
textMeasurer = textMeasurer,
constraints = constraints,
textStyle = textStyle,
density = density
density = density,
imageSizeMultiplier = imageSizeMultiplier
)
Timber.d("paginateChapter: Calling PaginatorLogic for chapter $chapterIndex.")
val pages = paginate(
@ -971,31 +982,7 @@ class BookPaginator(
return@launch
}
val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex)
val chapterStartPage = calculateAccurateStartIndex(targetChapterIndex)
if (chapterPages == null) {
Timber.e("Href Navigation failed: Could not paginate target chapter $targetChapterIndex.")
return@launch
}
var targetPageInChapter = 0
if (anchor != null) {
Timber.d("Searching for anchor '$anchor' in chapter $targetChapterIndex.")
pageLoop@ for ((pageIndex, page) in chapterPages.withIndex()) {
for (block in page.content) {
if (block.elementId == anchor) {
targetPageInChapter = pageIndex
Timber.i("Found anchor '$anchor' on page $pageIndex in chapter $targetChapterIndex")
break@pageLoop
}
}
}
}
val finalPageIndex = chapterStartPage + targetPageInChapter
Timber.i("Href navigation complete. Final page index: $finalPageIndex")
withContext(Dispatchers.Main) { onNavigationComplete(finalPageIndex) }
findPageForAnchor(targetChapterIndex, anchor, onNavigationComplete)
}
}
@ -1304,4 +1291,4 @@ class BookPaginator(
return null to null
}
}
}
}

View file

@ -144,7 +144,8 @@ class PaginatedReaderViewModel : ViewModel() {
context = context.applicationContext,
mathMLRenderer = mathMLRenderer,
userTextAlign = null,
paragraphGapMultiplier = paragraphGapMultiplier
paragraphGapMultiplier = paragraphGapMultiplier,
imageSizeMultiplier = 1.0f
)
paginator = newPaginator
@ -174,4 +175,4 @@ class PaginatedReaderViewModel : ViewModel() {
fun onLinkClick(currentChapterPath: String, href: String, onNavigationComplete: (Int) -> Unit) {
paginator?.navigateToHref(currentChapterPath, href, onNavigationComplete)
}
}
}

View file

@ -50,7 +50,8 @@ class SuspendingAndroidBlockMeasurementProvider(
private val textMeasurer: TextMeasurer,
private val constraints: Constraints,
private val textStyle: TextStyle,
private val density: Density
private val density: Density,
private val imageSizeMultiplier: Float
) : BlockMeasurementProvider {
override suspend fun measure(block: ContentBlock): Int {
@ -60,7 +61,8 @@ class SuspendingAndroidBlockMeasurementProvider(
constraints = constraints,
defaultStyle = textStyle,
headerStyle = textStyle.copy(fontWeight = FontWeight.Bold),
density = density
density = density,
imageSizeMultiplier = imageSizeMultiplier
)
}
@ -79,29 +81,12 @@ class SuspendingAndroidBlockMeasurementProvider(
val imageBlock = block.floatedImage
val (imageWidthPx, imageHeightPx) = run {
val imageStyle = imageBlock.style
val intrinsicWidth = imageBlock.intrinsicWidth
val intrinsicHeight = imageBlock.intrinsicHeight
if (intrinsicWidth == null || intrinsicHeight == null || intrinsicWidth <= 0f) {
0f to 0f
} else {
val aspectRatio = intrinsicHeight / intrinsicWidth
val renderWidth = with(density) {
var w = intrinsicWidth
if (imageStyle.width != Dp.Unspecified) {
w = imageStyle.width.toPx()
}
if (imageStyle.maxWidth != Dp.Unspecified) {
w = w.coerceAtMost(imageStyle.maxWidth.toPx())
}
w.coerceAtMost(constraints.maxWidth.toFloat())
}
renderWidth to (renderWidth * aspectRatio)
}
measureScaledImageSizePx(
block = imageBlock,
density = density,
maxWidthPx = constraints.maxWidth.toFloat(),
imageSizeMultiplier = imageSizeMultiplier
)
}
if (imageWidthPx <= 0 || imageHeightPx <= 0) {
@ -668,62 +653,25 @@ private suspend fun measureBlockHeight(
constraints: Constraints,
defaultStyle: TextStyle,
headerStyle: TextStyle,
density: Density
density: Density,
imageSizeMultiplier: Float = 1.0f
): Int {
var verticalPaddingPx = 0f
var horizontalPaddingPx = 0f
var verticalBorderPx = 0f
var horizontalBorderPx = 0f
with(density) {
verticalPaddingPx = block.style.padding.top.toPx() + block.style.padding.bottom.toPx()
horizontalPaddingPx = block.style.padding.left.toPx() + block.style.padding.right.toPx()
verticalBorderPx = (block.style.borderTop?.width?.toPx() ?: 0f) + (block.style.borderBottom?.width?.toPx() ?: 0f)
horizontalBorderPx = (block.style.borderLeft?.width?.toPx() ?: 0f) + (block.style.borderRight?.width?.toPx() ?: 0f)
}
val isBorderBox = block.style.boxSizing == "border-box"
val specifiedWidthDp = block.style.width
val specifiedMaxWidthDp = block.style.maxWidth
val blockOuterWidthPx = with(density) {
var effectiveWidthPx = constraints.maxWidth.toFloat()
if (specifiedWidthDp != Dp.Unspecified) {
effectiveWidthPx = specifiedWidthDp.toPx()
}
if (specifiedMaxWidthDp != Dp.Unspecified) {
val maxWidthPx = specifiedMaxWidthDp.toPx()
if (effectiveWidthPx > maxWidthPx) {
effectiveWidthPx = maxWidthPx
}
}
effectiveWidthPx.coerceAtMost(constraints.maxWidth.toFloat())
}
val contentMaxWidth = if (specifiedWidthDp == Dp.Unspecified) {
(blockOuterWidthPx - horizontalPaddingPx - horizontalBorderPx)
} else if (isBorderBox) {
(blockOuterWidthPx - horizontalPaddingPx - horizontalBorderPx)
} else {
blockOuterWidthPx
}
val adjustedConstraints = constraints.copy(
maxWidth = contentMaxWidth.roundToInt().coerceAtLeast(0),
maxHeight = Constraints.Infinity
)
val boxMetrics = computeBlockBoxMetrics(block, constraints, density)
val verticalPaddingPx = boxMetrics.verticalPaddingPx
val verticalBorderPx = boxMetrics.verticalBorderPx
val adjustedConstraints = boxMetrics.contentConstraints
val contentHeight = when (block) {
is ParagraphBlock -> {
val paragraphStyle = defaultStyle.copy(textAlign = block.textAlign ?: defaultStyle.textAlign)
val height = withContext(Dispatchers.Main) {
textMeasurer.measure(
text = block.content,
style = defaultStyle.copy(textAlign = block.textAlign ?: defaultStyle.textAlign),
style = paragraphStyle,
constraints = adjustedConstraints
).size.height
}
height
height + centeredTextSafetyPaddingPx(paragraphStyle, density)
}
is HeaderBlock -> {
val style = headerStyle.copy(
@ -736,27 +684,15 @@ private suspend fun measureBlockHeight(
constraints = adjustedConstraints
).size.height
}
height
height + centeredTextSafetyPaddingPx(style, density)
}
is ImageBlock -> {
val imageIntrinsicWidth = block.intrinsicWidth
val imageIntrinsicHeight = block.intrinsicHeight
val styledHeightPx = if (block.style.height.isSpecified) with(density) { block.style.height.toPx() } else null
val styledWidthPx = if (block.style.width.isSpecified) with(density) { block.style.width.toPx() } else null
val measuredHeight = when {
styledHeightPx != null && styledHeightPx > 0f -> styledHeightPx
styledWidthPx != null && styledWidthPx > 0f && imageIntrinsicWidth != null && imageIntrinsicHeight != null && imageIntrinsicWidth > 0 -> {
val aspectRatio = imageIntrinsicHeight / imageIntrinsicWidth
styledWidthPx * aspectRatio
}
imageIntrinsicWidth != null && imageIntrinsicHeight != null && imageIntrinsicWidth > 0 -> {
val aspectRatio = imageIntrinsicHeight / imageIntrinsicWidth
contentMaxWidth * aspectRatio
}
else -> with(density) { 250.dp.toPx() }
}
val measuredHeight = measureScaledImageHeightPx(
block = block,
density = density,
contentMaxWidth = adjustedConstraints.maxWidth.toFloat(),
imageSizeMultiplier = imageSizeMultiplier
) ?: with(density) { 250.dp.toPx() }
val finalHeight = measuredHeight.coerceAtMost(constraints.maxHeight.toFloat()).roundToInt()
Timber.tag("IMAGE_DIAG").d("Measured Image [#${block.blockIndex}]: $finalHeight px (Capped at ${constraints.maxHeight})")
@ -767,14 +703,15 @@ private suspend fun measureBlockHeight(
height
}
is QuoteBlock -> {
val quoteStyle = defaultStyle.copy(textAlign = block.textAlign ?: defaultStyle.textAlign)
val height = withContext(Dispatchers.Main) {
textMeasurer.measure(
text = block.content,
style = defaultStyle.copy(textAlign = block.textAlign ?: defaultStyle.textAlign),
style = quoteStyle,
constraints = adjustedConstraints
).size.height
}
height
height + centeredTextSafetyPaddingPx(quoteStyle, density)
}
is ListItemBlock -> {
val markerWidthPx = with(density) { 32.dp.toPx() }.toInt()
@ -811,7 +748,7 @@ private suspend fun measureBlockHeight(
val cellConstraints = adjustedConstraints.copy(maxWidth = cellMaxWidth.coerceAtLeast(0))
val cellContentHeight = calculateContentHeightWithMargins(cell.content, textMeasurer, cellConstraints, defaultStyle, headerStyle, density)
val cellContentHeight = calculateContentHeightWithMargins(cell.content, textMeasurer, cellConstraints, defaultStyle, headerStyle, density, imageSizeMultiplier)
var cellDecorationHeight = 0f
with(density) {
@ -829,35 +766,18 @@ private suspend fun measureBlockHeight(
val imageBlock = block.floatedImage
val (imageWidthPx, imageHeightPx) = run {
val imageStyle = imageBlock.style
val intrinsicWidth = imageBlock.intrinsicWidth
val intrinsicHeight = imageBlock.intrinsicHeight
if (intrinsicWidth == null || intrinsicHeight == null || intrinsicWidth <= 0f) {
0f to 0f
} else {
val aspectRatio = intrinsicHeight / intrinsicWidth
val renderWidth = with(density) {
var w = intrinsicWidth
if (imageStyle.width != Dp.Unspecified) {
w = imageStyle.width.toPx()
}
if (imageStyle.maxWidth != Dp.Unspecified) {
w = w.coerceAtMost(imageStyle.maxWidth.toPx())
}
w.coerceAtMost(adjustedConstraints.maxWidth.toFloat())
}
renderWidth to (renderWidth * aspectRatio)
}
measureScaledImageSizePx(
block = imageBlock,
density = density,
maxWidthPx = adjustedConstraints.maxWidth.toFloat(),
imageSizeMultiplier = imageSizeMultiplier
)
}
// If image has no size, it can't float. Just measure the paragraphs.
if (imageWidthPx <= 0 || imageHeightPx <= 0) {
val height = block.paragraphsToWrap.sumOf { p ->
measureBlockHeight(p, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density)
measureBlockHeight(p, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density, imageSizeMultiplier)
}
return height
}
@ -949,10 +869,10 @@ private suspend fun measureBlockHeight(
val isRow = block.style.flexDirection == "row"
val height = if (isRow) {
block.children.maxOfOrNull { child ->
measureBlockHeight(child, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density)
measureBlockHeight(child, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density, imageSizeMultiplier)
} ?: 0
} else {
calculateContentHeightWithMargins(block.children, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density)
calculateContentHeightWithMargins(block.children, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density, imageSizeMultiplier)
}
height
}
@ -980,7 +900,7 @@ private suspend fun measureBlockHeight(
}
}
val specifiedHeightDp = block.style.height
val finalHeight = if (isBorderBox && specifiedHeightDp != Dp.Unspecified) {
val finalHeight = if (block.style.boxSizing == "border-box" && specifiedHeightDp != Dp.Unspecified) {
with(density) { specifiedHeightDp.toPx().roundToInt() }
} else {
(contentHeight + verticalPaddingPx + verticalBorderPx).roundToInt()
@ -1000,6 +920,10 @@ private suspend fun splitParagraphBlock(
): Pair<ParagraphBlock, ParagraphBlock>? {
val text = block.content
if (text.isEmpty()) return null
val boxMetrics = computeBlockBoxMetrics(block, constraints, density)
val paragraphConstraints = boxMetrics.contentConstraints
val paragraphStyle = textStyle.copy(textAlign = block.textAlign ?: textStyle.textAlign)
val centeredSafetyPaddingPx = centeredTextSafetyPaddingPx(paragraphStyle, density)
val decorationTop = with(density) {
block.style.padding.top.toPx() + (block.style.borderTop?.width?.toPx() ?: 0f)
@ -1009,7 +933,7 @@ private suspend fun splitParagraphBlock(
block.style.padding.bottom.toPx() + (block.style.borderBottom?.width?.toPx() ?: 0f)
}.roundToInt()
val availableTextHeight = availableHeight - decorationTop - decorationBottom
val availableTextHeight = availableHeight - decorationTop - decorationBottom - centeredSafetyPaddingPx
Timber.tag("PAGINATION_DEBUG").d("SplitPara: totalAvail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom, textAvail=$availableTextHeight")
@ -1021,8 +945,8 @@ private suspend fun splitParagraphBlock(
val layoutResult = withContext(Dispatchers.Main) {
textMeasurer.measure(
text = text,
style = textStyle,
constraints = constraints.copy(maxHeight = Constraints.Infinity)
style = paragraphStyle,
constraints = paragraphConstraints
)
}
@ -1036,7 +960,7 @@ private suspend fun splitParagraphBlock(
var lastVisibleLine = layoutResult.getLineForVerticalPosition(availableTextHeight.toFloat())
if (layoutResult.getLineBottom(lastVisibleLine) > availableHeight.toFloat()) {
if (layoutResult.getLineBottom(lastVisibleLine) > availableTextHeight.toFloat()) {
lastVisibleLine--
}
@ -1056,7 +980,8 @@ private suspend fun splitParagraphBlock(
val part2Layout = withContext(Dispatchers.Main) {
textMeasurer.measure(
text = part2CheckText,
constraints = constraints
style = paragraphStyle,
constraints = paragraphConstraints
)
}
if (part2Layout.lineCount == 1) {
@ -1149,11 +1074,12 @@ private suspend fun calculateContentHeightWithMargins(
constraints: Constraints,
defaultStyle: TextStyle,
headerStyle: TextStyle,
density: Density
density: Density,
imageSizeMultiplier: Float = 1.0f
): Int {
var totalHeight = 0
children.forEachIndexed { index, child ->
val childHeight = measureBlockHeight(child, textMeasurer, constraints, defaultStyle, headerStyle, density)
val childHeight = measureBlockHeight(child, textMeasurer, constraints, defaultStyle, headerStyle, density, imageSizeMultiplier)
val margin = with(density) {
if (index > 0) {
val prevMargin = children[index - 1].style.margin.bottom.toPx()
@ -1172,6 +1098,117 @@ private suspend fun calculateContentHeightWithMargins(
return totalHeight
}
private data class BlockBoxMetrics(
val verticalPaddingPx: Float,
val verticalBorderPx: Float,
val contentConstraints: Constraints
)
private fun computeBlockBoxMetrics(
block: ContentBlock,
constraints: Constraints,
density: Density
): BlockBoxMetrics {
val verticalPaddingPx: Float
val horizontalPaddingPx: Float
val verticalBorderPx: Float
val horizontalBorderPx: Float
with(density) {
verticalPaddingPx = block.style.padding.top.toPx() + block.style.padding.bottom.toPx()
horizontalPaddingPx = block.style.padding.left.toPx() + block.style.padding.right.toPx()
verticalBorderPx = (block.style.borderTop?.width?.toPx() ?: 0f) + (block.style.borderBottom?.width?.toPx() ?: 0f)
horizontalBorderPx = (block.style.borderLeft?.width?.toPx() ?: 0f) + (block.style.borderRight?.width?.toPx() ?: 0f)
}
val isBorderBox = block.style.boxSizing == "border-box"
val specifiedWidthDp = block.style.width
val specifiedMaxWidthDp = block.style.maxWidth
val blockOuterWidthPx = with(density) {
var effectiveWidthPx = constraints.maxWidth.toFloat()
if (specifiedWidthDp != Dp.Unspecified) {
effectiveWidthPx = specifiedWidthDp.toPx()
}
if (specifiedMaxWidthDp != Dp.Unspecified) {
val maxWidthPx = specifiedMaxWidthDp.toPx()
if (effectiveWidthPx > maxWidthPx) {
effectiveWidthPx = maxWidthPx
}
}
effectiveWidthPx.coerceAtMost(constraints.maxWidth.toFloat())
}
val contentMaxWidth = if (specifiedWidthDp == Dp.Unspecified || isBorderBox) {
blockOuterWidthPx - horizontalPaddingPx - horizontalBorderPx
} else {
blockOuterWidthPx
}
return BlockBoxMetrics(
verticalPaddingPx = verticalPaddingPx,
verticalBorderPx = verticalBorderPx,
contentConstraints = constraints.copy(
maxWidth = contentMaxWidth.roundToInt().coerceAtLeast(0),
maxHeight = Constraints.Infinity
)
)
}
private fun centeredTextSafetyPaddingPx(
style: TextStyle,
density: Density
): Int {
if (style.textAlign != androidx.compose.ui.text.style.TextAlign.Center) return 0
val fallbackLineHeight = if (style.fontSize.isSpecified) {
style.fontSize * 1.2f
} else {
16.sp * 1.2f
}
val effectiveLineHeight = if (style.lineHeight.isSpecified) style.lineHeight else fallbackLineHeight
return with(density) { effectiveLineHeight.toPx().roundToInt() }
}
private fun measureScaledImageHeightPx(
block: ImageBlock,
density: Density,
contentMaxWidth: Float,
imageSizeMultiplier: Float
): Float? = measureScaledImageSizePx(
block = block,
density = density,
maxWidthPx = contentMaxWidth,
imageSizeMultiplier = imageSizeMultiplier
).second.takeIf { it > 0f }
private fun measureScaledImageSizePx(
block: ImageBlock,
density: Density,
maxWidthPx: Float,
imageSizeMultiplier: Float
): Pair<Float, Float> {
val intrinsicWidth = block.intrinsicWidth
val intrinsicHeight = block.intrinsicHeight
if (intrinsicWidth == null || intrinsicHeight == null || intrinsicWidth <= 0f || intrinsicHeight <= 0f) {
return 0f to 0f
}
val aspectRatio = intrinsicHeight / intrinsicWidth
val baseWidth = with(density) {
if (block.style.width.isSpecified) block.style.width.toPx() else maxWidthPx
}
var scaledWidth = baseWidth * imageSizeMultiplier
if (block.style.maxWidth.isSpecified) {
scaledWidth = scaledWidth.coerceAtMost(with(density) { block.style.maxWidth.toPx() } * imageSizeMultiplier)
}
scaledWidth = scaledWidth.coerceAtMost(maxWidthPx)
return scaledWidth to (scaledWidth * aspectRatio)
}
private fun zeroOutBottomMargin(blocks: MutableList<ContentBlock>) {
if (blocks.isNotEmpty()) {
val lastBlock = blocks.last()
@ -1180,4 +1217,4 @@ private fun zeroOutBottomMargin(blocks: MutableList<ContentBlock>) {
setBlockExpectedHeight(copyBlockWithNewStyle(lastBlock, newLastStyle), lastBlock.expectedHeight)
blocks[blocks.size - 1] = newLastBlock
}
}
}

View file

@ -198,7 +198,7 @@ abstract class BookCacheDao {
ConfigurationCache::class,
AnchorIndexEntry::class
],
version = 7,
version = 8,
exportSchema = false
)
abstract class BookCacheDatabase : RoomDatabase() {

View file

@ -25,7 +25,7 @@ import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
const val LATEST_PROCESSING_VERSION = 7
const val LATEST_PROCESSING_VERSION = 8
@Entity(tableName = "processed_books")
data class ProcessedBook(