5f90a01314
Previously the chapter reader showed vocab tables as a flat list of OCR lines — because Vision reads columns top-to-bottom, the Spanish column appeared as one block followed by the English column, making pairings illegible. Now every vocab table renders as a 2-column grid with Spanish on the left and English on the right. Supporting changes: - New ocr_all_vocab.swift: bounding-box OCR over all 931 vocab images, cluster lines into rows by Y-coordinate, split rows by largest X-gap, detect 2- / 3- / 4-column layouts automatically. ~2800 pairs extracted this pass vs ~1100 from the old block-alternation heuristic. - merge_pdf_into_book.py now prefers bounding-box pairs when present, falls back to the heuristic, embeds the resulting pairs as vocab_table.cards in book.json. - DataLoader passes cards through to TextbookBlock on seed. - TextbookChapterView renders cards via SwiftUI Grid (2 cols). - fix_vocab.py quarantine rule relaxed — only mis-pairs where both sides are clearly the same language are removed. "unknown" sides stay (bbox pipeline already oriented them correctly). Textbook card count jumps from 1044 → 3118 active pairs. textbookDataVersion bumped to 9. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
210 lines
7.0 KiB
Swift
210 lines
7.0 KiB
Swift
import SwiftUI
|
|
import SharedModels
|
|
import SwiftData
|
|
|
|
struct TextbookChapterView: View {
|
|
let chapter: TextbookChapter
|
|
|
|
@State private var expandedVocab: Set<Int> = []
|
|
|
|
private var blocks: [TextbookBlock] { chapter.blocks() }
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
headerView
|
|
Divider()
|
|
ForEach(blocks) { block in
|
|
blockView(block)
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 12)
|
|
}
|
|
.navigationTitle(chapter.title)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
|
|
private var headerView: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
if chapter.part > 0 {
|
|
Text("Part \(chapter.part)")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Text("Chapter \(chapter.number)")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
Text(chapter.title)
|
|
.font(.largeTitle.bold())
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func blockView(_ block: TextbookBlock) -> some View {
|
|
switch block.kind {
|
|
case .heading:
|
|
headingView(block)
|
|
case .paragraph:
|
|
paragraphView(block)
|
|
case .keyVocabHeader:
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "star.fill").foregroundStyle(.orange)
|
|
Text("Key Vocabulary")
|
|
.font(.headline)
|
|
.foregroundStyle(.orange)
|
|
}
|
|
.padding(.top, 8)
|
|
case .vocabTable:
|
|
vocabTableView(block)
|
|
case .exercise:
|
|
exerciseLinkView(block)
|
|
}
|
|
}
|
|
|
|
private func headingView(_ block: TextbookBlock) -> some View {
|
|
let level = block.level ?? 3
|
|
let font: Font
|
|
switch level {
|
|
case 2: font = .title.bold()
|
|
case 3: font = .title2.bold()
|
|
case 4: font = .title3.weight(.semibold)
|
|
default: font = .headline
|
|
}
|
|
return Text(stripInlineEmphasis(block.text ?? ""))
|
|
.font(font)
|
|
.padding(.top, 10)
|
|
}
|
|
|
|
private func paragraphView(_ block: TextbookBlock) -> some View {
|
|
Text(attributedFromMarkdownish(block.text ?? ""))
|
|
.font(.body)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
private func vocabTableView(_ block: TextbookBlock) -> some View {
|
|
let expanded = expandedVocab.contains(block.index)
|
|
let cards = block.cards ?? []
|
|
let lines = block.ocrLines ?? []
|
|
let itemCount = cards.isEmpty ? lines.count : cards.count
|
|
return VStack(alignment: .leading, spacing: 4) {
|
|
Button {
|
|
if expanded { expandedVocab.remove(block.index) } else { expandedVocab.insert(block.index) }
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: expanded ? "chevron.down" : "chevron.right")
|
|
.font(.caption)
|
|
Text("Vocabulary (\(itemCount) items)")
|
|
.font(.subheadline.weight(.medium))
|
|
.foregroundStyle(.primary)
|
|
Spacer()
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
if expanded {
|
|
if cards.isEmpty {
|
|
// Fallback: no paired cards available — show raw OCR lines.
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
|
|
Text(line)
|
|
.font(.callout.monospaced())
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(.leading, 14)
|
|
} else {
|
|
vocabGrid(cards: cards)
|
|
.padding(.leading, 14)
|
|
}
|
|
}
|
|
}
|
|
.padding(10)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Color.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func vocabGrid(cards: [TextbookVocabPair]) -> some View {
|
|
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
|
|
ForEach(Array(cards.enumerated()), id: \.offset) { _, card in
|
|
GridRow {
|
|
Text(card.front)
|
|
.font(.callout)
|
|
.foregroundStyle(.primary)
|
|
Text(card.back)
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func exerciseLinkView(_ block: TextbookBlock) -> some View {
|
|
NavigationLink(value: TextbookExerciseDestination(
|
|
chapterId: chapter.id,
|
|
chapterNumber: chapter.number,
|
|
blockIndex: block.index
|
|
)) {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: "pencil.and.list.clipboard")
|
|
.foregroundStyle(.orange)
|
|
.font(.title3)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Exercise \(block.exerciseId ?? "")")
|
|
.font(.headline)
|
|
if let inst = block.instruction, !inst.isEmpty {
|
|
Text(stripInlineEmphasis(inst))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
}
|
|
}
|
|
Spacer()
|
|
Image(systemName: "chevron.right")
|
|
.foregroundStyle(.secondary)
|
|
.font(.caption)
|
|
}
|
|
.padding(12)
|
|
.background(Color.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
// Strip our ad-hoc ** / * markers from parsed text
|
|
private func stripInlineEmphasis(_ s: String) -> String {
|
|
s.replacingOccurrences(of: "**", with: "")
|
|
.replacingOccurrences(of: "*", with: "")
|
|
}
|
|
|
|
private func attributedFromMarkdownish(_ s: String) -> AttributedString {
|
|
// Parser emits `**bold**` and `*italic*`. Try to render via AttributedString markdown.
|
|
if let parsed = try? AttributedString(markdown: s, options: .init(allowsExtendedAttributes: true)) {
|
|
return parsed
|
|
}
|
|
return AttributedString(stripInlineEmphasis(s))
|
|
}
|
|
}
|
|
|
|
struct TextbookExerciseDestination: Hashable {
|
|
let chapterId: String
|
|
let chapterNumber: Int
|
|
let blockIndex: Int
|
|
}
|
|
|
|
#Preview {
|
|
NavigationStack {
|
|
TextbookChapterView(chapter: TextbookChapter(
|
|
id: "ch1",
|
|
number: 1,
|
|
title: "Sample",
|
|
part: 1,
|
|
courseName: "Preview",
|
|
bodyJSON: Data(),
|
|
exerciseCount: 0,
|
|
vocabTableCount: 0
|
|
))
|
|
}
|
|
}
|