This commit is contained in:
2026-09-02 23:47:00 +03:00
parent b4d0b0c8e9
commit a8830bcd91
77 changed files with 2998 additions and 0 deletions
@@ -0,0 +1,244 @@
package agzam.vyatsu.data.remote.pdf
import android.graphics.Path
import android.graphics.PointF
import com.tom_roush.pdfbox.contentstream.PDFGraphicsStreamEngine
import com.tom_roush.pdfbox.cos.COSName
import com.tom_roush.pdfbox.pdmodel.PDPage
import com.tom_roush.pdfbox.pdmodel.graphics.color.PDColor
import com.tom_roush.pdfbox.pdmodel.graphics.image.PDImage
import java.util.Locale
import kotlin.math.max
import kotlin.math.min
/**
* - Парсит страницу PDF в набор линий
* - Позволяет получить таблицу
* - Позволяет конвертировать в SVG (для отладки)
*
* Требуется вызов [processPage] для парсинга
* @author Agzam
*/
class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
data class PdfLine(val x1: Float, val y1: Float, val x2: Float, val y2: Float) {
val left: Float get() = min(x1, x2)
val right: Float get() = max(x1, x2)
val top: Float get() = min(y1, y2)
val bottom: Float get() = max(y1, y2)
}
data class TableColumn(val x: Float, val width: Float, val cells: List<TableCell>) {
val right: Float get() = x + width
}
data class TableCell(val x: Float, val y: Float, val width: Float, val height: Float) {
val centerY: Float get() = y + height/2f
}
private val _lines = mutableListOf<PdfLine>()
private val svgBuilder = StringBuilder()
private val currentPath = StringBuilder()
private val tableSvg = StringBuilder()
private val currentSubPathLines = mutableListOf<PdfLine>()
private var lastPoint = PointF(0f, 0f)
/**
* Представляет страницу в SVG формате
*/
fun asSvg(): String {
val mediaBox = page.mediaBox
val width = mediaBox.width
val height = mediaBox.height
return """
<svg width="$width" height="$height" viewBox="0 0 $width $height" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g>${svgBuilder}</g>
<g>${tableSvg}</g>
</svg>
""".trimIndent()
}
override fun appendRectangle(rp0: PointF?, rp1: PointF?, rp2: PointF?, rp3: PointF?) {
if (rp0 == null || rp1 == null || rp2 == null || rp3 == null) return
val p0 = t(rp0)
val p1 = t(rp1)
val p2 = t(rp2)
val p3 = t(rp3)
currentPath.append(String.format(Locale.US, "M %.2f %.2f L %.2f %.2f L %.2f %.2f L %.2f %.2f Z ", p0.x, p0.y, p1.x, p1.y, p2.x, p2.y, p3.x, p3.y))
currentSubPathLines.add(PdfLine(p0.x, p0.y, p1.x, p1.y))
currentSubPathLines.add(PdfLine(p1.x, p1.y, p2.x, p2.y))
currentSubPathLines.add(PdfLine(p2.x, p2.y, p3.x, p3.y))
currentSubPathLines.add(PdfLine(p3.x, p3.y, p0.x, p0.y))
lastPoint.set(p3.x, p3.y)
}
override fun moveTo(rx: Float, ry: Float) {
val x = tx(rx)
val y = ty(ry)
currentPath.append(String.format(Locale.US, "M %.2f %.2f ", x, y))
lastPoint.set(x, y)
}
override fun lineTo(rx: Float, ry: Float) {
val x = tx(rx)
val y = ty(ry)
currentPath.append(String.format(Locale.US, "L %.2f %.2f ", x, y))
currentSubPathLines.add(PdfLine(lastPoint.x, lastPoint.y, x, y))
lastPoint.set(x, y)
}
override fun curveTo(rcx1: Float, rcy1: Float, rcx2: Float, rcy2: Float, ex: Float, ey: Float) {
val cx1 = tx(rcx1)
val cy1 = ty(rcy1)
val cx2 = tx(rcx2)
val cy2 = ty(rcy2)
val endX = tx(ex)
val endY = ty(ey)
currentPath.append(String.format(Locale.US, "C %.2f %.2f, %.2f %.2f, %.2f %.2f ", cx1, cy1, cx2, cy2, endX, endY))
lastPoint.set(endX, endY)
}
override fun getCurrentPoint(): PointF {
return lastPoint
}
override fun closePath() {
currentPath.append("Z ")
}
override fun endPath() {
currentPath.clear()
currentSubPathLines.clear()
}
override fun strokePath() {
val strokeColor = graphicsState.strokingColor.toRGBHex()
val lineWidth = graphicsState.lineWidth
if (currentPath.isNotEmpty()) {
svgBuilder.append("<path d=\"$currentPath\" stroke=\"$strokeColor\" stroke-width=\"$lineWidth\" fill=\"none\" />\n")
_lines.addAll(currentSubPathLines)
}
currentPath.clear()
currentSubPathLines.clear()
}
override fun fillPath(p0: Path.FillType?) {
val fillColor = graphicsState.nonStrokingColor.toRGBHex()
val rule = if (p0 == Path.FillType.EVEN_ODD) "evenodd" else "nonzero"
if (currentPath.isNotEmpty()) {
svgBuilder.append("<path d=\"$currentPath\" fill=\"$fillColor\" fill-rule=\"$rule\" />\n")
_lines.addAll(currentSubPathLines)
}
currentPath.clear()
currentSubPathLines.clear()
}
override fun fillAndStrokePath(p0: Path.FillType?) {
val strokeColor = graphicsState.strokingColor.toRGBHex()
val fillColor = graphicsState.nonStrokingColor.toRGBHex()
val lineWidth = graphicsState.lineWidth
val rule = if (p0 == Path.FillType.EVEN_ODD) "evenodd" else "nonzero"
if (currentPath.isNotEmpty()) {
svgBuilder.append("<path d=\"$currentPath\" fill=\"$fillColor\" fill-rule=\"$rule\" stroke=\"$strokeColor\" stroke-width=\"$lineWidth\" />\n")
_lines.addAll(currentSubPathLines)
}
currentPath.clear()
currentSubPathLines.clear()
}
override fun shadingFill(p0: COSName?) {
currentPath.clear()
currentSubPathLines.clear()
}
private fun PDColor.toRGBHex(): String {
return try {
val rgb = this.toRGB()
String.format(Locale.US, "#%06X", rgb and 0xFFFFFF)
} catch (_: Exception) {
"#000000"
}
}
override fun drawImage(p0: PDImage?) {}
override fun clip(p0: Path.FillType?) {}
private fun tx(x: Float): Float {
return x
}
private fun ty(y: Float): Float {
return page.mediaBox.height - y
}
private fun t(p: PointF): PointF {
return PointF(tx(p.x), ty(p.y))
}
/**
* Представляет страницу как набор колонок с ячейками
*
* Сначала делит страницу на столбики, затем, изолированно для каждого столбика делит его на ячейки
*/
fun asTable(): List<TableColumn> {
val verticalLines = _lines.filter { it.x1 == it.x2 }
val horizontalLines = _lines.filter { it.y1 == it.y2 }
// Список уникальных "x", чтобы разделить весь лист на колонки
val xs = merge(verticalLines.map { it.x1 })
for (v in horizontalLines) {
tableSvg.append("<rect fill='#0000ff55' x='${v.left}' y='${v.top}' width='${v.right-v.left}' height='3'/>\n")
}
val columns = mutableListOf<TableColumn>()
for (i in 0 until xs.size - 1) {
val left = xs[i]
val right = xs[i + 1]
tableSvg.append("<rect fill='${if (i % 2 == 0) "#f00" else "#0f0"}' x='$left' y='3' width='${right-left}' height='3'/>\n")
val colWidth = right - left
if (colWidth <= 2f) continue
// Выборка горизонтальных линиий которые пересекают колонку
val linesInColumn = horizontalLines.filter { line -> (line.left-2f <= left && right <= line.right+2f) }
val ys = merge(linesInColumn.map { it.y1 }) // Уникальные "y", чтобы разделить уже столбик
val cells = mutableListOf<TableCell>()
for (ii in 0 until ys.size - 1) {
val top = ys[ii]
val bottom = ys[ii+1]
cells.add(TableCell(left, top, right-left, bottom-top))
tableSvg.append("<rect fill='${if (i % 2 == 0) "#f" else "#0"}${if (ii % 2 == 0) "f0" else "00"}' x='$left' y='$top' width='${right-left}' height='${bottom-top}'/>\n")
}
columns.add(TableColumn(left, right-left, cells))
}
return columns
}
/**
* Убирает дубликаты, схлопывает близкие точки
*/
private fun merge(fs: List<Float>): List<Float> {
val s = fs.distinct().sorted()
val result = mutableListOf<Float>()
if(s.isEmpty()) return result
result.add(s[0])
var pad = 0f
for (i in 0 until s.size - 1) {
val dst = s[i + 1] - s[i]
if (dst <= 4f) {
pad = dst/2f
result[result.size-1] = result.last() + pad
continue
}
result.add(s[i+1]+pad)
pad = 0f
}
return result
}
}
@@ -0,0 +1,147 @@
package agzam.vyatsu.data.remote.pdf
import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.Lesson
import agzam.vyatsu.domain.model.TimeSlot
import android.util.Log
import com.tom_roush.pdfbox.pdmodel.PDDocument
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import kotlin.math.roundToInt
/**
* Парсинг расписания из PDF
* @author Agzam
*/
object PdfScheduleParser {
private const val TAG = "PDF-SCHEDULE"
private const val SVG_TAG = "PDF-SVG"
private val TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm")
private val DATE_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yy")
private fun timeFrom(timeString: String?): LocalTime? { return runCatching { LocalTime.parse(timeString, TIME_FORMAT) }.getOrNull() }
private fun dateFrom(dateString: String?): LocalDate? { return runCatching { LocalDate.parse(dateString, DATE_FORMAT) }.getOrNull() }
fun parse(pdfBytes: ByteArray): List<Day> {
Log.d(TAG, "Parsing ${pdfBytes.size} bytes")
val doc = try {
PDDocument.load(pdfBytes)
} catch (t: Throwable) {
Log.e(TAG, "PDDocument.load failed", t)
throw t
}
val days = mutableListOf<Day>()
try {
for (pageIndex in 0 until doc.numberOfPages) {
Log.v(TAG, "============ Page $pageIndex ============")
// Парсинг ячеек таблицы
val page = doc.getPage(pageIndex)
val svg = Pdf2Table(page)
svg.processPage(page)
val cols = svg.asTable()
/** Вывод таблички из PDF в SVG формате + отладочные жлементы из [Pdf2Table.asTable] **/
Log.v(SVG_TAG, "====")
for(line in svg.asSvg().split("\n")) {
Log.v(SVG_TAG, line)
}
if(cols.size != 3) throw RuntimeException("Found columns: ${cols.size}/3")
// Парсинг текста
val textStripper = PdfTextParser()
textStripper.startPage = pageIndex + 1
textStripper.endPage = pageIndex + 1
textStripper.getText(doc)
val texts = textStripper.blocks
val daysColumn = cols[0] // Колонка "День", имеет формат "<день> dd.mm.yy"
val intervalsColumn = cols[1] // Колонка "Интервал", имеет формат "HH:mm-HH:mm"
val lessonsColumn = cols[2] // Колонка с инофрмацией о паре
Log.v(TAG, "Days: ${cols[0].cells.size}")
Log.v(TAG, "Times: ${cols[1].cells.size}")
Log.v(TAG, "Types: ${cols[2].cells.size}")
/** Мерджит ячейки в одну строку, пробел в качестве слиятиля */
fun join(texts: List<PdfTextParser.TextBlock>): String {
val text = StringBuilder()
for (t in texts) {
if(!text.isEmpty()) text.append(' ')
text.append(t.text)
}
return text.toString()
}
/**
* Извлекает контент из PDF внутри ячейки
* (Подразумевается что в ячейке нет "\n", поэтому используется пробел для слияния)
*/
fun content(cell: Pdf2Table.TableCell): String {
return join(texts.filter {
cell.x <= it.x && it.x <= cell.x + cell.width && cell.y <= it.y && it.y < cell.y + cell.height
})
}
/**
* Выбирает все ячейки в целевой колонке, которые находятся на одном уровне (строке) с заданной ячейкой
* @param column - целевая колонка, в которой производится поиск ячеек.
* @param cell - ячейка-ориентир, определяющая границы строки по вертикали.
* @return Список ячеек из [column], попавших в диапазон строки
*/
fun select(column: Pdf2Table.TableColumn, cell: Pdf2Table.TableCell): List<Pdf2Table.TableCell> {
return column.cells.filter {
cell.y <= it.centerY && it.centerY < cell.y + cell.height
}
}
for (dayCell in daysColumn.cells) {
val day = content(dayCell).split(' ')
if(day.size != 2) continue // XXX: добавить защиту от заголовка и выбрасывать исключения вместо пропуска?
val date = dateFrom(day[1]) ?: continue
val intervals = mutableListOf<TimeSlot>()
// TODO: день недели (а нужен ли?)
Log.v(TAG, "############################## $day ############################## ")
for(intervalCell in select(intervalsColumn, dayCell)) { // День содержит интервалы
val timeContent = content(intervalCell)
val range = timeContent.split('-')
if(range.size != 2) continue // XXX: выбрасывать ошибку?
val start = timeFrom(range[0]) ?: continue
val end = timeFrom(range[1]) ?: continue
val lessons = mutableListOf<Lesson>()
Log.v(TAG, "=== $timeContent ===")
for(lessonCell in select(lessonsColumn, intervalCell)) { // Информация о паре, может быть разбита, например на подгруппы
val content = content(lessonCell)
Log.v(TAG, "- $content")
// TODO: парсинг контента лекции
lessons.add(Lesson(raw=content))
}
intervals.add(TimeSlot(start = start, end = end, lessons = lessons))
}
days.add(Day(date=date, intervals=intervals))
}
for (text in texts.filter { it.rotation != 0f }) {
Log.v(TAG, "${text.x.roundToInt()}\t${text.y.roundToInt()}\t${text.text}")
}
}
} catch (e: Exception) {
Log.e(TAG, "Parsing error", e)
e.printStackTrace()
} finally {
doc.close()
}
Log.v(TAG, "Days extracted: $days")
return days
}
}
@@ -0,0 +1,45 @@
package agzam.vyatsu.data.remote.pdf
import com.tom_roush.pdfbox.text.PDFTextStripper
import com.tom_roush.pdfbox.text.TextPosition
/**
* Парсер блоков текста из PDF
* @author Agzam
*/
class PdfTextParser : PDFTextStripper() {
data class TextBlock(
val text: String,
val x: Float,
val y: Float,
val width: Float,
val height: Float,
val rotation: Float
)
val blocks = mutableListOf<TextBlock>()
init {
sortByPosition = true
}
override fun writeString(string: String?, textPositions: MutableList<TextPosition>?) {
if (!string.isNullOrBlank() && !textPositions.isNullOrEmpty()) {
val first = textPositions.first()
val last = textPositions.last()
val matrix = first.textMatrix
val rotation = first.dir
val totalWidth = (last.xDirAdj + last.widthDirAdj) - first.xDirAdj
val x = matrix.translateX
val y = matrix.translateY - first.heightDir
val height = first.heightDir
blocks.add(TextBlock(string.trim(), x, currentPage.mediaBox.height - y - height, totalWidth, height, rotation))
}
}
}