-
-
Notifications
You must be signed in to change notification settings - Fork 103
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Rewrote reader layout engine. Adds real table support.
- Loading branch information
1 parent
0b19e6d
commit 4777d97
Showing
22 changed files
with
4,739 additions
and
2,035 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,7 +3,7 @@ name: Signed APKs | |
on: | ||
push: | ||
branches: | ||
- upgrades | ||
- table-layout | ||
|
||
jobs: | ||
signed_apk: | ||
|
944 changes: 944 additions & 0 deletions
944
app/src/main/java/com/nononsenseapps/feeder/model/html/HtmlLinearizer.kt
Large diffs are not rendered by default.
Oops, something went wrong.
340 changes: 340 additions & 0 deletions
340
app/src/main/java/com/nononsenseapps/feeder/model/html/LinearStuff.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,340 @@ | ||
package com.nononsenseapps.feeder.model.html | ||
|
||
import androidx.collection.ArrayMap | ||
|
||
data class LinearArticle( | ||
val elements: List<LinearElement>, | ||
) | ||
|
||
/** | ||
* A linear element can contain other linear elements | ||
*/ | ||
sealed interface LinearElement | ||
|
||
/** | ||
* Represents a list of items, ordered or unordered | ||
*/ | ||
data class LinearList( | ||
val ordered: Boolean, | ||
val items: List<LinearListItem>, | ||
) : LinearElement { | ||
fun isEmpty(): Boolean { | ||
return items.isEmpty() | ||
} | ||
|
||
fun isNotEmpty(): Boolean { | ||
return items.isNotEmpty() | ||
} | ||
|
||
class Builder(private val ordered: Boolean) { | ||
private val items: MutableList<LinearListItem> = mutableListOf() | ||
|
||
fun add(item: LinearListItem) { | ||
items.add(item) | ||
} | ||
|
||
fun build(): LinearList { | ||
return LinearList(ordered, items) | ||
} | ||
} | ||
|
||
companion object { | ||
fun build( | ||
ordered: Boolean, | ||
block: Builder.() -> Unit, | ||
): LinearList { | ||
return Builder(ordered).apply(block).build() | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* Represents a single item in a list | ||
*/ | ||
data class LinearListItem( | ||
val content: List<LinearElement>, | ||
) { | ||
constructor(block: ListBuilderScope<LinearElement>.() -> Unit) : this(content = ListBuilderScope(block).items) | ||
|
||
constructor(vararg elements: LinearElement) : this(content = elements.toList()) | ||
|
||
fun isEmpty(): Boolean { | ||
return content.isEmpty() | ||
} | ||
|
||
fun isNotEmpty(): Boolean { | ||
return content.isNotEmpty() | ||
} | ||
|
||
class Builder { | ||
private val content: MutableList<LinearElement> = mutableListOf() | ||
|
||
fun add(element: LinearElement) { | ||
content.add(element) | ||
} | ||
|
||
fun build(): LinearListItem { | ||
return LinearListItem(content) | ||
} | ||
} | ||
|
||
companion object { | ||
fun build(block: Builder.() -> Unit): LinearListItem { | ||
return Builder().apply(block).build() | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* Represents a table | ||
*/ | ||
data class LinearTable( | ||
val rowCount: Int, | ||
val colCount: Int, | ||
private val cellsReal: ArrayMap<Coordinate, LinearTableCellItem>, | ||
) : LinearElement { | ||
val cells: Map<Coordinate, LinearTableCellItem> | ||
get() = cellsReal | ||
|
||
constructor( | ||
rowCount: Int, | ||
colCount: Int, | ||
cells: List<LinearTableCellItem>, | ||
) : this( | ||
rowCount, | ||
colCount, | ||
ArrayMap<Coordinate, LinearTableCellItem>().apply { | ||
cells.forEachIndexed { index, item -> | ||
put(Coordinate(row = index / colCount, col = index % colCount), item) | ||
} | ||
}, | ||
) | ||
|
||
fun cellAt( | ||
row: Int, | ||
col: Int, | ||
): LinearTableCellItem? { | ||
return cells[Coordinate(row = row, col = col)] | ||
} | ||
|
||
class Builder { | ||
private val cells: ArrayMap<Coordinate, LinearTableCellItem> = ArrayMap() | ||
private var rowCount: Int = 0 | ||
private var colCount: Int = 0 | ||
private var currentRowColCount = 0 | ||
private var currentRow = 0 | ||
|
||
fun add(element: LinearTableCellItem) { | ||
check(rowCount > 0) { "Must add a row before adding cells" } | ||
|
||
// First find the first empty cell in this row | ||
var cellCoord = Coordinate(row = currentRow, col = currentRowColCount) | ||
while (cells[cellCoord] != null) { | ||
currentRowColCount++ | ||
cellCoord = cellCoord.copy(col = currentRowColCount) | ||
} | ||
|
||
currentRowColCount += element.colSpan | ||
if (currentRowColCount > colCount) { | ||
colCount = currentRowColCount | ||
} | ||
|
||
cells[cellCoord] = element | ||
|
||
// Insert filler elements for spanned cells | ||
for (r in 0 until element.rowSpan) { | ||
for (c in 0 until element.colSpan) { | ||
// Skip first since this is the cell itself | ||
if (r == 0 && c == 0) { | ||
continue | ||
} | ||
|
||
val fillerCoord = Coordinate(row = currentRow + r, col = currentRowColCount - element.colSpan + c) | ||
check(cells[fillerCoord] == null) { "Cell at filler $fillerCoord already exists" } | ||
cells[fillerCoord] = LinearTableCellItem.filler | ||
} | ||
} | ||
} | ||
|
||
fun newRow() { | ||
if (rowCount > 0) { | ||
currentRow++ | ||
} | ||
rowCount++ | ||
currentRowColCount = 0 | ||
} | ||
|
||
fun build(): LinearTable { | ||
return LinearTable(rowCount, colCount, cells) | ||
} | ||
} | ||
|
||
companion object { | ||
fun build(block: Builder.() -> Unit): LinearTable { | ||
return Builder().apply(block).build() | ||
} | ||
} | ||
} | ||
|
||
data class Coordinate( | ||
val row: Int, | ||
val col: Int, | ||
) | ||
|
||
/** | ||
* Represents a single cell in a table | ||
*/ | ||
data class LinearTableCellItem( | ||
val type: LinearTableCellItemType, | ||
val colSpan: Int, | ||
val rowSpan: Int, | ||
val content: List<LinearElement>, | ||
) { | ||
constructor( | ||
colSpan: Int, | ||
rowSpan: Int, | ||
type: LinearTableCellItemType, | ||
block: ListBuilderScope<LinearElement>.() -> Unit, | ||
) : this(colSpan = colSpan, rowSpan = rowSpan, type = type, content = ListBuilderScope(block).items) | ||
|
||
val isFiller | ||
get() = colSpan == filler.colSpan && rowSpan == filler.rowSpan | ||
|
||
class Builder( | ||
private val colSpan: Int, | ||
private val rowSpan: Int, | ||
private val type: LinearTableCellItemType, | ||
) { | ||
private val content: MutableList<LinearElement> = mutableListOf() | ||
|
||
fun add(element: LinearElement) { | ||
content.add(element) | ||
} | ||
|
||
fun build(): LinearTableCellItem { | ||
return LinearTableCellItem(colSpan = colSpan, rowSpan = rowSpan, type = type, content = content) | ||
} | ||
} | ||
|
||
companion object { | ||
fun build( | ||
colSpan: Int, | ||
rowSpan: Int, | ||
type: LinearTableCellItemType, | ||
block: Builder.() -> Unit, | ||
): LinearTableCellItem { | ||
return Builder(colSpan = colSpan, rowSpan = rowSpan, type = type).apply(block).build() | ||
} | ||
|
||
val filler = | ||
LinearTableCellItem( | ||
type = LinearTableCellItemType.DATA, | ||
colSpan = -1, | ||
rowSpan = -1, | ||
content = emptyList(), | ||
) | ||
} | ||
} | ||
|
||
enum class LinearTableCellItemType { | ||
HEADER, | ||
DATA, | ||
} | ||
|
||
data class LinearBlockQuote( | ||
val cite: String?, | ||
val content: List<LinearElement>, | ||
) : LinearElement { | ||
constructor(cite: String?, block: ListBuilderScope<LinearElement>.() -> Unit) : this(cite = cite, content = ListBuilderScope(block).items) | ||
|
||
constructor(cite: String?, vararg elements: LinearElement) : this(cite = cite, content = elements.toList()) | ||
} | ||
|
||
/** | ||
* Primitives can not contain other elements | ||
*/ | ||
sealed interface LinearPrimitive : LinearElement | ||
|
||
/** | ||
* Represents a text element. For example a paragraph, or a header. | ||
*/ | ||
data class LinearText( | ||
val text: String, | ||
val annotations: List<LinearTextAnnotation>, | ||
val blockStyle: LinearTextBlockStyle, | ||
) : LinearPrimitive { | ||
constructor(text: String, blockStyle: LinearTextBlockStyle, vararg annotations: LinearTextAnnotation) : this(text = text, blockStyle = blockStyle, annotations = annotations.toList()) | ||
} | ||
|
||
enum class LinearTextBlockStyle { | ||
TEXT, | ||
PRE_FORMATTED, | ||
CODE_BLOCK, | ||
} | ||
|
||
val LinearTextBlockStyle.shouldSoftWrap: Boolean | ||
get() = this == LinearTextBlockStyle.TEXT | ||
|
||
/** | ||
* Represents an image element | ||
*/ | ||
data class LinearImage( | ||
val sources: List<LinearImageSource>, | ||
val caption: LinearText?, | ||
val link: String?, | ||
) : LinearElement | ||
|
||
data class LinearImageSource( | ||
val imgUri: String, | ||
val widthPx: Int?, | ||
val heightPx: Int?, | ||
val pixelDensity: Float?, | ||
val screenWidth: Int?, | ||
) | ||
|
||
/** | ||
* Represents a video element | ||
*/ | ||
data class LinearVideo( | ||
val sources: List<LinearVideoSource>, | ||
) : LinearElement { | ||
init { | ||
require(sources.isNotEmpty()) { "At least one source must be provided" } | ||
} | ||
|
||
val imageThumbnail: String? by lazy { | ||
sources.firstOrNull { it.imageThumbnail != null }?.imageThumbnail | ||
} | ||
|
||
val firstSource: LinearVideoSource | ||
get() = sources.first() | ||
} | ||
|
||
data class LinearVideoSource( | ||
val uri: String, | ||
// This might be different from the uri, for example for youtube videos where uri is the embed uri | ||
val link: String, | ||
val imageThumbnail: String?, | ||
val widthPx: Int?, | ||
val heightPx: Int?, | ||
val mimeType: String?, | ||
) | ||
|
||
/** | ||
* Represents an audio element | ||
*/ | ||
data class LinearAudio( | ||
val sources: List<LinearAudioSource>, | ||
) : LinearElement { | ||
init { | ||
require(sources.isNotEmpty()) { "At least one source must be provided" } | ||
} | ||
|
||
val firstSource: LinearAudioSource | ||
get() = sources.first() | ||
} | ||
|
||
data class LinearAudioSource( | ||
val uri: String, | ||
val mimeType: String?, | ||
) |
Oops, something went wrong.