renderreport 0.3.2

Data-driven report generation with Typst as embedded render engine — no CLI dependency
Documentation
// WordSearch Component
// Generates a parameter-driven word search grid with word list and solution highlighting

#let word-search(data) = {
  let cols = data.width
  let rows = data.height
  let cell-sz = 20pt
  let word-cols = if data.at("columns_word_list", default: none) != none { data.columns_word_list } else { 3 }
  let show-sol = data.at("show_solution", default: false)

  block(width: 100%, breakable: false)[
    #if data.title != none [
      #component-title(text(weight: "bold", size: font-size-lg, fill: color-text)[#data.title])
      #v(spacing-2)
    ]

    #if data.description != none [
      #text(size: font-size-sm, fill: color-text-muted)[#data.description]
      #v(spacing-3)
    ]

    #align(center)[
      #block(
        stroke: 1pt + color-border,
        radius: 6pt,
        inset: spacing-2,
        fill: color-surface,
      )[
        #table(
          columns: range(cols).map(_ => cell-sz),
          rows: range(rows).map(_ => cell-sz),
          align: center + horizon,
          stroke: 0.5pt + color-border.lighten(50%),
          fill: (col, row) => {
            let row-cells = data.grid.at(row)
            let cell = row-cells.at(col)
            if show-sol and cell.is_solution {
              color-accent-soft
            } else {
              color-surface
            }
          },
          ..data.grid.flatten().map(cell => [
            #text(
              weight: if show-sol and cell.is_solution { "bold" } else { "medium" },
              fill: if show-sol and cell.is_solution { color-primary } else { color-text },
              size: font-size-base,
            )[#cell.char]
          ])
        )
      ]
    ]

    #v(spacing-4)

    #if data.words.len() > 0 [
      #text(weight: "semibold", size: font-size-sm, fill: color-text-muted)[#data.word_list_label (#data.words.len()):]
      #v(spacing-2)
      #grid(
        columns: range(word-cols).map(_ => 1fr),
        gutter: spacing-2,
        ..data.words.map(w => [
          #block(
            width: 100%,
            fill: color-surface-alt,
            inset: (x: 8pt, y: 5pt),
            radius: 4pt,
            stroke: 0.5pt + color-border,
          )[
            #text(size: font-size-sm, weight: "bold", fill: color-text)[#w.text]
            #if w.at("translation", default: none) != none [
              #v(3pt)
              #if show-sol [
                #text(size: font-size-xs, fill: color-text-muted, style: "italic")[#w.translation]
              ] else [
                #box(width: 100%, height: 18pt, fill: color-surface, stroke: 0.5pt + color-border, radius: 2pt)
              ]
            ]
          ]
        ])
      )
    ]
  ]
}