Compare commits

...
Author SHA1 Message Date
Agzamus 014896aaea ui: no more empty lines in schedule 2026-09-05 00:21:39 +03:00
Agzamus 9f6cf3ef30 fix: #1 - text blocks by words 2026-09-05 00:13:38 +03:00
4 changed files with 110 additions and 43 deletions
@@ -38,7 +38,7 @@ class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
private val svgBuilder = StringBuilder() private val svgBuilder = StringBuilder()
private val currentPath = StringBuilder() private val currentPath = StringBuilder()
private val tableSvg = StringBuilder() val tableSvg = StringBuilder()
private val currentSubPathLines = mutableListOf<PdfLine>() private val currentSubPathLines = mutableListOf<PdfLine>()
@@ -52,7 +52,7 @@ class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
val width = mediaBox.width val width = mediaBox.width
val height = mediaBox.height val height = mediaBox.height
return """ 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"> <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" style='font-size: 50%;'>
<g>${svgBuilder}</g> <g>${svgBuilder}</g>
<g>${tableSvg}</g> <g>${tableSvg}</g>
</svg> </svg>
@@ -211,7 +211,7 @@ class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
val bottom = ys[ii+1] val bottom = ys[ii+1]
cells.add(TableCell(left, top, right-left, bottom-top)) 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") tableSvg.append("<rect fill='${if (i % 2 == 0) "#ff" else "#9e"}${if (ii % 2 == 0) "ff9e" else "9e9e"}' x='$left' y='$top' width='${right-left}' height='${bottom-top}'/>\n")
} }
columns.add(TableColumn(left, right-left, cells)) columns.add(TableColumn(left, right-left, cells))
} }
@@ -52,12 +52,6 @@ object PdfScheduleParser {
svg.processPage(page) svg.processPage(page)
val cols = svg.asTable() 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") if(cols.size != 3) throw RuntimeException("Found columns: ${cols.size}/3")
// Парсинг текста // Парсинг текста
@@ -67,6 +61,11 @@ object PdfScheduleParser {
textStripper.getText(doc) textStripper.getText(doc)
val texts = textStripper.blocks val texts = textStripper.blocks
for(text in texts) {
svg.tableSvg.appendLine("<text x='${text.x}' y='${text.y}'>${text.text}</text>")
svg.tableSvg.appendLine("<circle cx='${text.x}' cy='${text.y}' r='1' fill='red'/>")
}
val daysColumn = cols[0] // Колонка "День", имеет формат "<день> dd.mm.yy" val daysColumn = cols[0] // Колонка "День", имеет формат "<день> dd.mm.yy"
val intervalsColumn = cols[1] // Колонка "Интервал", имеет формат "HH:mm-HH:mm" val intervalsColumn = cols[1] // Колонка "Интервал", имеет формат "HH:mm-HH:mm"
val lessonsColumn = cols[2] // Колонка с инофрмацией о паре val lessonsColumn = cols[2] // Колонка с инофрмацией о паре
@@ -114,7 +113,10 @@ object PdfScheduleParser {
group = content(groupCell) group = content(groupCell)
continue continue
} }
if(day.size != 2) continue // XXX: добавить защиту от заголовка и выбрасывать исключения вместо пропуска? if(day.size != 2) {
Log.w(TAG, "Day data is not in format <day> <date>: $day")
continue // XXX: добавить защиту от заголовка и выбрасывать исключения вместо пропуска?
}
val date = dateFrom(day[1]) ?: continue val date = dateFrom(day[1]) ?: continue
val intervals = mutableListOf<TimeSlot>() val intervals = mutableListOf<TimeSlot>()
@@ -161,7 +163,7 @@ object PdfScheduleParser {
val type = LessonType.fromRaw(typeMatch.first().value.trim()) val type = LessonType.fromRaw(typeMatch.first().value.trim())
val teacher = raw.substring(typeMatch.last().range.last+1, raw.length - room.length).trim() val teacher = raw.substring(typeMatch.last().range.last+1, raw.length - room.length).trim()
var subgroup: String? = null var subgroup: String? = null
Log.d(TAG, subject + " " + group) Log.d(TAG, "$subject $group")
if(group != null && subject.startsWith("$group,")) { if(group != null && subject.startsWith("$group,")) {
subject = subject.substring(group.length + 1).trim() subject = subject.substring(group.length + 1).trim()
val subgroupMatch = SUBGROUP_REGEX.find(subject) val subgroupMatch = SUBGROUP_REGEX.find(subject)
@@ -192,6 +194,18 @@ object PdfScheduleParser {
for (text in texts.filter { it.rotation != 0f }) { for (text in texts.filter { it.rotation != 0f }) {
Log.v(TAG, "${text.x.roundToInt()}\t${text.y.roundToInt()}\t${text.text}") Log.v(TAG, "${text.x.roundToInt()}\t${text.y.roundToInt()}\t${text.text}")
} }
/** Вывод таблички из PDF в SVG формате + отладочные жлементы из [Pdf2Table.asTable] **/
Log.v(SVG_TAG, "====")
for(line in svg.asSvg().split("\n")) {
if(line.length > 250) {
Log.v(SVG_TAG, "length: " + line.length)
Log.v(SVG_TAG + "-" + (pageIndex+1), "length: " + line.length)
continue
}
Log.v(SVG_TAG, line)
Log.v(SVG_TAG + "-" + (pageIndex+1), line)
}
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Parsing error", e) Log.e(TAG, "Parsing error", e)
@@ -7,6 +7,7 @@ import com.tom_roush.pdfbox.text.TextPosition
* Парсер блоков текста из PDF * Парсер блоков текста из PDF
* @author Agzam * @author Agzam
*/ */
class PdfTextParser : PDFTextStripper() { class PdfTextParser : PDFTextStripper() {
data class TextBlock( data class TextBlock(
@@ -25,9 +26,32 @@ class PdfTextParser : PDFTextStripper() {
} }
override fun writeString(string: String?, textPositions: MutableList<TextPosition>?) { override fun writeString(string: String?, textPositions: MutableList<TextPosition>?) {
if (!string.isNullOrBlank() && !textPositions.isNullOrEmpty()) { if (textPositions.isNullOrEmpty()) return
val first = textPositions.first()
val last = textPositions.last() val currentWordChars = mutableListOf<TextPosition>()
for (tp in textPositions) {
val unicode = tp.unicode
if (unicode == " " || unicode == "\u00A0" || unicode == "\t") {
addWordBlock(currentWordChars)
currentWordChars.clear()
} else {
currentWordChars.add(tp)
}
}
addWordBlock(currentWordChars)
}
private fun addWordBlock(wordChars: List<TextPosition>) {
if (wordChars.isEmpty()) return
val first = wordChars.first()
val last = wordChars.last()
val wordText = wordChars.joinToString("") { it.unicode }.trim()
if (wordText.isEmpty()) return
val matrix = first.textMatrix val matrix = first.textMatrix
val rotation = first.dir val rotation = first.dir
@@ -36,10 +60,17 @@ class PdfTextParser : PDFTextStripper() {
val x = matrix.translateX val x = matrix.translateX
val y = matrix.translateY - first.heightDir val y = matrix.translateY - first.heightDir
val height = first.heightDir val height = first.heightDir
blocks.add(TextBlock(string.trim(), x, currentPage.mediaBox.height - y - height, totalWidth, height, rotation)) blocks.add(
} TextBlock(
text = wordText,
x = x,
y = currentPage.mediaBox.height - y - height,
width = totalWidth,
height = height,
rotation = rotation
)
)
} }
} }
@@ -1,6 +1,7 @@
package agzam.vyatsu.ui.schedule package agzam.vyatsu.ui.schedule
import agzam.vyatsu.domain.model.Day import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.LessonType
import agzam.vyatsu.domain.model.SchedulePeriod import agzam.vyatsu.domain.model.SchedulePeriod
import agzam.vyatsu.domain.model.TimeSlot import agzam.vyatsu.domain.model.TimeSlot
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -285,27 +286,48 @@ private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
} else { } else {
slot.lessons.forEach { lesson -> slot.lessons.forEach { lesson ->
Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) { Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
var any = false
if(lesson.type != LessonType.OTHER) {
Text( Text(
lesson.type.label, lesson.type.label,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
any = true
}
val subject = lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw
if(subject.isNotEmpty()) {
Text( Text(
lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw, subject,
style = MaterialTheme.typography.bodyLarge style = MaterialTheme.typography.bodyLarge
) )
Text( any = true
listOfNotNull( }
val meta = listOfNotNull(
lesson.subgroup, lesson.subgroup,
lesson.teacher, lesson.teacher,
lesson.room lesson.room
) ).filter { it.isNotBlank() }.joinToString(" · ")
.filter { it.isNotBlank() }
.joinToString(" · "), if(meta.isNotEmpty()) {
Text(
meta,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
any = true
}
if(!any) {
Text(
"-",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
} }
} }