Files
Spanish/Conjuga/Conjuga/Views/Course/TextbookChapterView.swift
T
Trey T 5f90a01314 Render textbook vocab as paired Spanish→English grid
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>
2026-04-19 15:58:41 -05:00

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
))
}
}