Add Typst template for soroban flashcard generation
- Implement draw-soroban function for rendering abacus beads - Create flashcard layout with front/back support - Add generate-flashcards main function for batch generation - Support configurable layouts (6/8/9 cards per page) - Include duplex printing alignment with mirrored backs - Pure vector graphics using Typst drawing primitives 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
466efad640
commit
1312eef345
|
|
@ -0,0 +1,290 @@
|
||||||
|
#let draw-soroban(value, columns: auto, show-empty: false) = {
|
||||||
|
// Parse the value into digits
|
||||||
|
let digits = if type(value) == int {
|
||||||
|
str(value).clusters().map(d => int(d))
|
||||||
|
} else {
|
||||||
|
panic("Value must be an integer")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine number of columns
|
||||||
|
let num-columns = if columns == auto {
|
||||||
|
digits.len()
|
||||||
|
} else {
|
||||||
|
columns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pad with leading zeros if needed
|
||||||
|
let padded-digits = if digits.len() < num-columns {
|
||||||
|
(0,) * (num-columns - digits.len()) + digits
|
||||||
|
} else {
|
||||||
|
digits.slice(calc.max(0, digits.len() - num-columns))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip leading zeros if show-empty is false
|
||||||
|
let start-idx = if not show-empty {
|
||||||
|
let first-nonzero = padded-digits.position(d => d != 0)
|
||||||
|
if first-nonzero == none { 0 } else { first-nonzero }
|
||||||
|
} else { 0 }
|
||||||
|
|
||||||
|
let display-digits = padded-digits.slice(start-idx)
|
||||||
|
if display-digits.len() == 0 { display-digits = (0,) }
|
||||||
|
|
||||||
|
// Drawing parameters
|
||||||
|
let rod-width = 3pt
|
||||||
|
let bead-size = 12pt
|
||||||
|
let bead-spacing = 4pt
|
||||||
|
let column-spacing = 25pt
|
||||||
|
let heaven-earth-gap = 20pt
|
||||||
|
let inactive-color = gray.lighten(70%)
|
||||||
|
let active-color = black
|
||||||
|
let bar-thickness = 2pt
|
||||||
|
|
||||||
|
// Calculate total width and height
|
||||||
|
let total-width = display-digits.len() * column-spacing
|
||||||
|
let total-height = heaven-earth-gap + 5 * (bead-size + bead-spacing) + 10pt
|
||||||
|
|
||||||
|
box(width: total-width, height: total-height)[
|
||||||
|
#place(top + left)[
|
||||||
|
#for (idx, digit) in display-digits.enumerate() [
|
||||||
|
#let x-offset = idx * column-spacing + column-spacing / 2
|
||||||
|
|
||||||
|
// Decompose digit into heaven (5s) and earth (1s) beads
|
||||||
|
#let heaven-active = if digit >= 5 { 1 } else { 0 }
|
||||||
|
#let earth-active = calc.rem(digit, 5)
|
||||||
|
|
||||||
|
// Draw rod
|
||||||
|
#place(
|
||||||
|
dx: x-offset - rod-width / 2,
|
||||||
|
dy: 0pt,
|
||||||
|
rect(
|
||||||
|
width: rod-width,
|
||||||
|
height: total-height,
|
||||||
|
fill: gray.lighten(80%),
|
||||||
|
stroke: none
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Draw heaven bead
|
||||||
|
#let heaven-y = if heaven-active == 1 {
|
||||||
|
heaven-earth-gap - bead-size - 2pt // Active (touching bar)
|
||||||
|
} else {
|
||||||
|
5pt // Inactive (at top)
|
||||||
|
}
|
||||||
|
|
||||||
|
#place(
|
||||||
|
dx: x-offset - bead-size / 2,
|
||||||
|
dy: heaven-y,
|
||||||
|
circle(
|
||||||
|
radius: bead-size / 2,
|
||||||
|
fill: if heaven-active == 1 { active-color } else { inactive-color },
|
||||||
|
stroke: 0.5pt + black
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Draw earth beads
|
||||||
|
#for i in range(4) [
|
||||||
|
#let is-active = i < earth-active
|
||||||
|
#let earth-y = if is-active {
|
||||||
|
heaven-earth-gap + bar-thickness + 2pt + i * (bead-size + bead-spacing)
|
||||||
|
} else {
|
||||||
|
total-height - (4 - i) * (bead-size + bead-spacing) - 5pt
|
||||||
|
}
|
||||||
|
|
||||||
|
#place(
|
||||||
|
dx: x-offset - bead-size / 2,
|
||||||
|
dy: earth-y,
|
||||||
|
circle(
|
||||||
|
radius: bead-size / 2,
|
||||||
|
fill: if is-active { active-color } else { inactive-color },
|
||||||
|
stroke: 0.5pt + black
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
// Draw reckoning bar
|
||||||
|
#place(
|
||||||
|
dx: 0pt,
|
||||||
|
dy: heaven-earth-gap,
|
||||||
|
rect(
|
||||||
|
width: total-width,
|
||||||
|
height: bar-thickness,
|
||||||
|
fill: black,
|
||||||
|
stroke: none
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#let flashcard(
|
||||||
|
front-content,
|
||||||
|
back-content,
|
||||||
|
card-width: 3.5in,
|
||||||
|
card-height: 2.5in,
|
||||||
|
safe-margin: 5mm,
|
||||||
|
show-cut-marks: false,
|
||||||
|
show-registration: false
|
||||||
|
) = {
|
||||||
|
let card = rect(
|
||||||
|
width: card-width,
|
||||||
|
height: card-height,
|
||||||
|
stroke: if show-cut-marks { 0.25pt + gray } else { none },
|
||||||
|
radius: 0pt
|
||||||
|
)[
|
||||||
|
#box(
|
||||||
|
width: card-width - 2 * safe-margin,
|
||||||
|
height: card-height - 2 * safe-margin,
|
||||||
|
inset: safe-margin
|
||||||
|
)[
|
||||||
|
#align(center + horizon)[
|
||||||
|
#front-content
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
// Registration mark
|
||||||
|
#if show-registration {
|
||||||
|
place(
|
||||||
|
bottom + right,
|
||||||
|
dx: -2mm,
|
||||||
|
dy: -2mm,
|
||||||
|
circle(radius: 0.5mm, fill: gray.lighten(70%))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
(
|
||||||
|
front: card,
|
||||||
|
back: rect(
|
||||||
|
width: card-width,
|
||||||
|
height: card-height,
|
||||||
|
stroke: if show-cut-marks { 0.25pt + gray } else { none },
|
||||||
|
radius: 0pt
|
||||||
|
)[
|
||||||
|
#box(
|
||||||
|
width: card-width - 2 * safe-margin,
|
||||||
|
height: card-height - 2 * safe-margin,
|
||||||
|
inset: safe-margin
|
||||||
|
)[
|
||||||
|
#align(center + horizon)[
|
||||||
|
#back-content
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
// Registration mark
|
||||||
|
#if show-registration {
|
||||||
|
place(
|
||||||
|
bottom + left,
|
||||||
|
dx: 2mm,
|
||||||
|
dy: -2mm,
|
||||||
|
circle(radius: 0.5mm, fill: gray.lighten(70%))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#let generate-flashcards(
|
||||||
|
numbers,
|
||||||
|
cards-per-page: 6,
|
||||||
|
paper-size: "us-letter",
|
||||||
|
orientation: "portrait",
|
||||||
|
margins: (top: 0.5in, bottom: 0.5in, left: 0.5in, right: 0.5in),
|
||||||
|
gutter: 5mm,
|
||||||
|
show-cut-marks: false,
|
||||||
|
show-registration: false,
|
||||||
|
font-family: "DejaVu Sans",
|
||||||
|
font-size: 48pt,
|
||||||
|
columns: auto,
|
||||||
|
show-empty-columns: false
|
||||||
|
) = {
|
||||||
|
// Set document properties
|
||||||
|
set document(title: "Soroban Flashcards", author: "Soroban Flashcard Generator")
|
||||||
|
set page(
|
||||||
|
paper: paper-size,
|
||||||
|
margin: margins,
|
||||||
|
flipped: orientation == "landscape"
|
||||||
|
)
|
||||||
|
|
||||||
|
set text(font: font-family, size: font-size, fallback: true)
|
||||||
|
|
||||||
|
// Calculate card dimensions
|
||||||
|
let page-width = if orientation == "portrait" { 8.5in } else { 11in }
|
||||||
|
let page-height = if orientation == "portrait" { 11in } else { 8.5in }
|
||||||
|
|
||||||
|
let usable-width = page-width - margins.left - margins.right
|
||||||
|
let usable-height = page-height - margins.top - margins.bottom
|
||||||
|
|
||||||
|
// Determine grid layout
|
||||||
|
let (cols, rows) = if cards-per-page == 6 {
|
||||||
|
(2, 3)
|
||||||
|
} else if cards-per-page == 4 {
|
||||||
|
(2, 2)
|
||||||
|
} else if cards-per-page == 8 {
|
||||||
|
(2, 4)
|
||||||
|
} else if cards-per-page == 9 {
|
||||||
|
(3, 3)
|
||||||
|
} else {
|
||||||
|
panic("Unsupported cards-per-page value")
|
||||||
|
}
|
||||||
|
|
||||||
|
let card-width = (usable-width - gutter * (cols - 1)) / cols
|
||||||
|
let card-height = (usable-height - gutter * (rows - 1)) / rows
|
||||||
|
|
||||||
|
// Generate cards
|
||||||
|
let cards = numbers.map(num => {
|
||||||
|
flashcard(
|
||||||
|
draw-soroban(num, columns: columns, show-empty: show-empty-columns),
|
||||||
|
text(size: font-size)[#num],
|
||||||
|
card-width: card-width,
|
||||||
|
card-height: card-height,
|
||||||
|
show-cut-marks: show-cut-marks,
|
||||||
|
show-registration: show-registration
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Layout pages
|
||||||
|
let total-cards = cards.len()
|
||||||
|
let total-pages = calc.ceil(total-cards / cards-per-page)
|
||||||
|
|
||||||
|
for page-idx in range(total-pages) {
|
||||||
|
let start-idx = page-idx * cards-per-page
|
||||||
|
let end-idx = calc.min(start-idx + cards-per-page, total-cards)
|
||||||
|
let page-cards = cards.slice(start-idx, end-idx)
|
||||||
|
|
||||||
|
// Front side
|
||||||
|
grid(
|
||||||
|
columns: (card-width,) * cols,
|
||||||
|
rows: (card-height,) * rows,
|
||||||
|
column-gutter: gutter,
|
||||||
|
row-gutter: gutter,
|
||||||
|
..page-cards.map(c => c.front)
|
||||||
|
)
|
||||||
|
|
||||||
|
if page-idx < total-pages - 1 or end-idx == total-cards {
|
||||||
|
pagebreak()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back side (mirrored for duplex printing)
|
||||||
|
grid(
|
||||||
|
columns: (card-width,) * cols,
|
||||||
|
rows: (card-height,) * rows,
|
||||||
|
column-gutter: gutter,
|
||||||
|
row-gutter: gutter,
|
||||||
|
..range(rows).map(r => {
|
||||||
|
range(cols).rev().map(c => {
|
||||||
|
let idx = r * cols + c
|
||||||
|
if idx < page-cards.len() {
|
||||||
|
page-cards.at(idx).back
|
||||||
|
} else {
|
||||||
|
rect(width: card-width, height: card-height, stroke: none)[]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}).flatten()
|
||||||
|
)
|
||||||
|
|
||||||
|
if page-idx < total-pages - 1 {
|
||||||
|
pagebreak()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue